diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6aad4b6..4ed5be2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,8 @@ You can add a plugin for a library or tool that isn't covered yet, or improve on 1. Copy `templates/plugin/` to `plugins//`. Plugin names start with `rf-` and describe the technology, for example `rf-database`. 2. Fill in `catalog.yaml`: a one-line summary, `tier: community`, `status: active`, yourself under `maintainers`, the libraries with the version ranges you checked, the Robot Framework range and the agents you tried. The schema is `schemas/catalog.schema.json`. + + For each library, list under `skills` the skills of your plugin that cover it, so a tool that detects the library in a project can pick the right skill (for example `skills: [rf-browser]` for Browser in rf-web). Leave it out only when every skill of the plugin applies. When you add, rename or remove a skill, update `skills` too; the catalogue check fails on a skill that doesn't exist. 3. Rename the example skill to `rf-` (the folder name and the `name` in `SKILL.md` must match) and write it. Skill and subagent names must be unique across every plugin. 4. Add a line for your plugin to `.github/CODEOWNERS` with the same people as `maintainers`. 5. Regenerate the catalogue and the marketplace: `python scripts/build-catalog.py`. diff --git a/docs/catalog.md b/docs/catalog.md index ee05edf..29577b7 100644 --- a/docs/catalog.md +++ b/docs/catalog.md @@ -45,8 +45,8 @@ RequestsLibrary and RESTinstance skills for API tests. - **Version:** 1.0.0 - **Robot Framework:** `>=7.0` - **Libraries:** - - RequestsLibrary (`robotframework-requests` `>=0.9.7`) - - RESTinstance (`RESTinstance` `>=1.8,<2`) + - RequestsLibrary (`robotframework-requests` `>=0.9.7`): `rf-requests` + - RESTinstance (`RESTinstance` `>=1.8,<2`): `rf-restinstance` - **Tested with:** claude-code, copilot-cli, vscode-copilot, codex, cursor, opencode, goose, claude-desktop - **Maintainers:** [@manykarim](https://github.com/manykarim) - **Install (Claude Code):** `/plugin install rf-api@robotframework-plugins` @@ -72,7 +72,7 @@ PlatynUI skill for desktop UI tests. - **Version:** 1.0.0 - **Robot Framework:** `>=7.0` - **Libraries:** - - PlatynUI (`robotframework-PlatynUI` `>=0.12.0.dev0`) + - PlatynUI (`robotframework-PlatynUI` `>=0.12.0.dev0`): `rf-platynui` - **Tested with:** claude-code, copilot-cli, vscode-copilot, codex, cursor, opencode, goose, claude-desktop - **Maintainers:** [@manykarim](https://github.com/manykarim) - **Install (Claude Code):** `/plugin install rf-desktop@robotframework-plugins` @@ -86,7 +86,7 @@ AppiumLibrary skill for Android and iOS tests. - **Version:** 1.0.0 - **Robot Framework:** `>=7.0` - **Libraries:** - - AppiumLibrary (`robotframework-appiumlibrary` `>=3,<4`) + - AppiumLibrary (`robotframework-appiumlibrary` `>=3,<4`): `rf-appium` - **Tested with:** claude-code, copilot-cli, vscode-copilot, codex, cursor, opencode, goose, claude-desktop - **Maintainers:** [@manykarim](https://github.com/manykarim) - **Install (Claude Code):** `/plugin install rf-mobile@robotframework-plugins` @@ -100,8 +100,8 @@ Browser Library and SeleniumLibrary skills for web tests. - **Version:** 1.0.0 - **Robot Framework:** `>=7.0` - **Libraries:** - - Browser (`robotframework-browser` `>=20,<21`) - - SeleniumLibrary (`robotframework-seleniumlibrary` `>=6.9,<7`) + - Browser (`robotframework-browser` `>=20,<21`): `rf-browser` + - SeleniumLibrary (`robotframework-seleniumlibrary` `>=6.9,<7`): `rf-selenium` - **Tested with:** claude-code, copilot-cli, vscode-copilot, codex, cursor, opencode, goose, claude-desktop - **Maintainers:** [@manykarim](https://github.com/manykarim) - **Install (Claude Code):** `/plugin install rf-web@robotframework-plugins` diff --git a/installer/CHANGELOG.md b/installer/CHANGELOG.md index 661ede4..d2a698c 100644 --- a/installer/CHANGELOG.md +++ b/installer/CHANGELOG.md @@ -11,6 +11,7 @@ The installer has calendar versions (`YYYY.M.PATCH`), independent of the plugins ### Added - **A public Python API, `robotframework_plugins.bundle`** ([#5](https://github.com/MarketSquare/robotframework-plugins/issues/5)), so other tools such as rf-mcp can depend on this package and serve the bundled skills: `catalog()` (the bundled plugins with their libraries and supported Robot Framework range), `skills_dirs()` (each active plugin's `skills/` folder, also when the package is imported from a zip) and `CATALOG_FORMAT` (`1`). `fetch(ref="stable")` returns the same for a branch, tag or commit of the repository, downloaded once into the user cache and checked before use; it is the only part of the package that uses the network, and only when called. See "Python API" in the README for the compatibility promise. - **`catalog.json` carries `"format": 1`**, and each entry its `robotframework` range. +- **Each library names the skills that cover it** (`libraries[].skills`, [#7](https://github.com/MarketSquare/robotframework-plugins/issues/7)), for example Browser → `rf-browser`, so a tool that detects one library in a project can pick its skill rather than every skill of the plugin. - **Several plugins.** The installer bundles every listed plugin (`rf-core`, `rf-web`, `rf-api`, `rf-mobile`, `rf-desktop` and the `rf-agentskills` bundle). By default it installs `rf-core` plus the plugins for the libraries the project declares in `pyproject.toml` or `requirements*.txt`, or every verified plugin when it declares none. `--plugins |all` chooses them; an unknown or removed plugin is an error, a deprecated one a warning. - **`robotframework-plugins catalog [--json]`** lists the bundled plugins with version, tier and status. `version` prints every bundled plugin's version. - **Upgrades keep what is installed.** On an upgrade, `install` covers the plugins already installed and adds the ones the project now declares. The summary names the skills and plugins an install removed, including a plugin the catalogue no longer has; files you changed are kept and reported. diff --git a/installer/README.md b/installer/README.md index 8ef3458..fcc76d0 100644 --- a/installer/README.md +++ b/installer/README.md @@ -97,7 +97,7 @@ Other tools can depend on `robotframework-plugins` and read the plugins it bundl | Name | What it gives you | |---|---| | `CATALOG_FORMAT` | The catalogue format, `1`. Check it at runtime. | -| `catalog()` | The bundled plugins (active and deprecated): `name`, `version`, `status`, `tier`, `summary`, `libraries` (`name`, `package`, `versions`), `robotframework`, `dependencies`, `bundle`, `bundled`. A new list on each call. | +| `catalog()` | The bundled plugins (active and deprecated): `name`, `version`, `status`, `tier`, `summary`, `libraries` (`name`, `package`, `versions`, and `skills`: the plugin's skills that cover the library; absent means all of them), `robotframework`, `dependencies`, `bundle`, `bundled`. A new list on each call. | | `skills_dirs()` | `{plugin: Path}` of each active plugin's `skills/` folder, `rf-core` first; bundles such as `rf-agentskills` are left out. The folders exist for the life of the process. | | `fetch(ref="stable")` | The same for a branch, tag or commit of this repository: a `Snapshot` with `ref`, `commit`, `catalog()` and `skills_dirs()`. Raises `FetchError` on any failure. | @@ -112,7 +112,8 @@ installed = {"robotframework-browser": "20.1.0"} for plugin in bundle.catalog(): for lib in plugin["libraries"]: if lib["package"] in installed: - print(plugin["name"], lib["name"], installed[lib["package"]] in SpecifierSet(lib["versions"])) + print(plugin["name"], lib["name"], lib.get("skills", "all skills"), + installed[lib["package"]] in SpecifierSet(lib["versions"])) # Skill folders, each holding /SKILL.md and references/ skills = bundle.skills_dirs() diff --git a/openspec/changes/catalog-library-skills/.openspec.yaml b/openspec/changes/catalog-library-skills/.openspec.yaml new file mode 100644 index 0000000..22ca07d --- /dev/null +++ b/openspec/changes/catalog-library-skills/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-13 diff --git a/openspec/changes/catalog-library-skills/design.md b/openspec/changes/catalog-library-skills/design.md new file mode 100644 index 0000000..571e654 --- /dev/null +++ b/openspec/changes/catalog-library-skills/design.md @@ -0,0 +1,45 @@ +## Context + +See proposal.md (Why). What the change builds on: + +- `schemas/catalog.schema.json` describes `libraries` items with `additionalProperties: false` and the keys `name`, `package` and `versions`. `scripts/build-catalog.py` validates every `plugins//catalog.yaml` against it, runs its own checks (`name_problems`, `symlink_problems`, `bundle_problems`, lifecycle and renames), and writes the README table, `docs/catalog.md` and the marketplace. +- The installer's `_catalog.py` (#5) copies `libraries` as it is into `catalog.json`. `bundle.catalog()` and `bundle.fetch()` read that, so a new key in a library entry reaches them with no installer code change. +- `rf-agentskills` is a bundle with `libraries: []`. A removed plugin keeps its manifest but leaves the catalogue. + +## Goals / Non-Goals + +**Goals:** +- One authoritative mapping from a library to the skills that cover it, checked against the skills on disk. +- No change for readers and community plugins that don't use the field. + +**Non-Goals:** +- Per-skill installs in the installer. +- Pointing a library at another plugin's skill. Skills are named within their own plugin, because the plugin is the unit a user installs. +- A `CATALOG_FORMAT` bump: the field is optional and new. + +## Decisions + +### D1. Optional, same-plugin skill names + +`skills` is optional, has at least one item, holds unique kebab-case names, and names skill folders of the same plugin. Absent means "every skill of the plugin", the only assumption a reader can make today, so existing catalogues keep their meaning. An empty list is rejected by `minItems: 1`: "no skill covers this library" would mean the library doesn't belong in the plugin's `libraries`. + +Alternative: a top-level `skills` map (`{skill: [library]}`). It would need a second lookup and would let a library go unmapped silently; per-library keeps the answer next to the package a tool detected. + +### D2. The check lives with the other catalogue checks + +A new `library_skill_problems(plugins)` in `build-catalog.py` runs for every manifest (active, deprecated and removed alike, so a stale entry is caught before a plugin is revived). For each library and each listed skill, it reports `: library lists skill , but plugins//skills//SKILL.md doesn't exist`. It runs in both `build-catalog.py` and `--check`, like `name_problems`. + +### D3. Where the field shows + +- `docs/catalog.md`: the library line becomes `Browser (robotframework-browser >=20,<21): rf-browser`. +- The README table keeps library names only: it is a summary and has no room. +- The marketplace has no field for it. + +### D4. Installer: tests and docs only + +No code change. The tests assert the field in `bundle.catalog()` (from the dev bundle) and in a `fetch()` snapshot (from the fake GitHub's archive). The README's "Python API" table lists `skills` among the library fields, with the "absent means every skill" rule. The changelog gets a line in the unreleased 2026.11.0 entry, next to the catalogue's `format` and `robotframework` additions. + +## Risks / Trade-offs + +- [A plugin adds a skill for a library and forgets `skills`] → The library keeps its old, narrower list, and tools miss the new skill. The check can't know intent; CONTRIBUTING.md asks plugin authors to update `skills` when they add or rename a skill. A rename that removes the old folder fails the check. +- [Community plugins never fill it in] → Readers fall back to all of the plugin's skills, which is today's behaviour. diff --git a/openspec/changes/catalog-library-skills/proposal.md b/openspec/changes/catalog-library-skills/proposal.md new file mode 100644 index 0000000..8cd6fb2 --- /dev/null +++ b/openspec/changes/catalog-library-skills/proposal.md @@ -0,0 +1,41 @@ +## Why + +The catalogue maps a library to a plugin, but a plugin can ship several skills for different libraries (rf-web: Browser and SeleniumLibrary; rf-api: RequestsLibrary and RESTinstance). A tool that detects one library can't tell which skill covers it ([issue #7](https://github.com/MarketSquare/robotframework-plugins/issues/7)). + +rf-mcp reads the catalogue through `bundle.catalog()` (#5) to attach skills to an agent's first tool call. In its evaluation at `37562367`, a Browser project that also had RequestsLibrary installed got `rf-requests`, `rf-restinstance` and `rf-browser`, in that order. rf-mcp now guesses the skill from its name (Browser → `rf-browser`). That's a naming convention, not a contract: a skill such as `rf-api-auth` or a company plugin breaks it. + +## What Changes + +- **Schema:** `libraries[].skills`, an optional list of skill names of the same plugin that cover the library, in `schemas/catalog.schema.json`. Other unknown keys stay rejected. +- **Check:** `scripts/build-catalog.py --check` fails when a listed skill has no `plugins//skills//SKILL.md`, naming the plugin, the library and the skill. +- **Data:** the four technology plugins name their skills per library: + - rf-web: Browser → `rf-browser`, SeleniumLibrary → `rf-selenium`; + - rf-api: RequestsLibrary → `rf-requests`, RESTinstance → `rf-restinstance`; + - rf-mobile: AppiumLibrary → `rf-appium`; + - rf-desktop: PlatynUI → `rf-platynui`. +- **Output:** + - `catalog.json` in the installer wheel, `bundle.catalog()` and `bundle.fetch()` snapshots carry the field unchanged; + - `docs/catalog.md` lists the skills next to each library. +- **Docs:** CONTRIBUTING.md (the `catalog.yaml` step) and the installer README's "Python API" table explain the field: when it's absent, every skill of the plugin covers the library. + +The field is optional and new, so `CATALOG_FORMAT` stays `1`, and catalogues and readers without it keep working. + +Out of scope: per-skill installs (`install` picking `rf-browser` without `rf-selenium`). The issue mentions that the installer could use the field; that would be a separate change. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `plugin-catalogue`: the programme manifest's `libraries` entries may name the skills that cover them, the catalogue check verifies those skills exist, and the generated catalogue and the installer's catalogue pass them on. (The capability is added by the unarchived change `launch-plugin-programme`; this change adds a requirement to it.) + +## Impact + +- **Schema and tooling:** `schemas/catalog.schema.json`, `scripts/build-catalog.py` (check and `docs/catalog.md`), `tests/test_catalog.py`. +- **Data:** `plugins/rf-web/catalog.yaml`, `plugins/rf-api/catalog.yaml`, `plugins/rf-mobile/catalog.yaml`, `plugins/rf-desktop/catalog.yaml`; regenerated `docs/catalog.md`. +- **Installer:** no code change, because `_catalog.py` already passes `libraries` through. Tests for `bundle.catalog()` and snapshots, the README "Python API" table, and a changelog line for 2026.11.0, which is still unreleased. +- **Docs:** `CONTRIBUTING.md`. +- **Consumers:** rf-mcp can map a detected package to its skill without guessing and fall back to all of a plugin's skills when `skills` is absent. diff --git a/openspec/changes/catalog-library-skills/specs/plugin-catalogue/spec.md b/openspec/changes/catalog-library-skills/specs/plugin-catalogue/spec.md new file mode 100644 index 0000000..6367248 --- /dev/null +++ b/openspec/changes/catalog-library-skills/specs/plugin-catalogue/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Libraries name the skills that cover them + +Each entry of a manifest's `libraries` MAY have `skills`: a non-empty list of distinct skill names (kebab-case) of the same plugin that cover that library. When `skills` is absent, every skill of the plugin covers the library. The manifest schema SHALL accept `skills` and SHALL keep rejecting other unknown keys of a library entry. The catalogue check SHALL fail when a listed skill has no `plugins//skills//SKILL.md`, naming the plugin, the library and the skill. The generated `docs/catalog.md` SHALL show the skills next to each library that names them, and the installer's bundled catalogue (`catalog.json`, `bundle.catalog()` and `bundle.fetch()` snapshots) SHALL carry `skills` unchanged. Every technology plugin of this repository SHALL name the skills of each of its libraries. + +#### Scenario: Skills per library accepted +- **WHEN** `plugins/rf-web/catalog.yaml` lists Browser with `skills: [rf-browser]` and SeleniumLibrary with `skills: [rf-selenium]` +- **THEN** schema validation and the catalogue check pass + +#### Scenario: Other unknown keys still rejected +- **WHEN** a library entry has a key `skill: rf-browser` (singular) +- **THEN** schema validation fails on that key + +#### Scenario: Missing skill reported +- **WHEN** rf-api lists RequestsLibrary with `skills: [rf-requests-auth]` and `plugins/rf-api/skills/rf-requests-auth/SKILL.md` doesn't exist +- **THEN** the catalogue check fails with a message naming `rf-api`, `RequestsLibrary` and `rf-requests-auth` + +#### Scenario: Passed through to tools +- **WHEN** a tool reads the `rf-web` entry of `bundle.catalog()` +- **THEN** its Browser library has `skills == ["rf-browser"]` and its SeleniumLibrary library has `skills == ["rf-selenium"]` + +#### Scenario: Absent field keeps working +- **WHEN** a community plugin's manifest lists a library without `skills` +- **THEN** validation passes, and its catalogue entry has no `skills` key for that library diff --git a/openspec/changes/catalog-library-skills/tasks.md b/openspec/changes/catalog-library-skills/tasks.md new file mode 100644 index 0000000..23e0382 --- /dev/null +++ b/openspec/changes/catalog-library-skills/tasks.md @@ -0,0 +1,21 @@ +## 1. Schema and check (D1, D2) + +- [x] 1.1 Add `skills` (array of unique kebab-case strings, `minItems: 1`) to `libraries.items.properties` in `schemas/catalog.schema.json`; verify tests in `tests/test_catalog.py` for "Skills per library accepted" and "Other unknown keys still rejected" (`skill:` singular), and an empty list rejected. +- [x] 1.2 Add `library_skill_problems()` to `scripts/build-catalog.py` and run it with the other checks; verify a test for "Missing skill reported" (message names the plugin, the library and the skill) and one for a removed plugin's manifest with a stale skill. + +## 2. Data and generated catalogue (D3) + +- [x] 2.1 Add `skills` to the libraries of rf-web, rf-api, rf-mobile and rf-desktop, as listed in the proposal; verify `scripts/build-catalog.py --check` passes after regenerating. +- [x] 2.2 Show the skills in `docs/catalog.md` (`Browser (robotframework-browser >=20,<21): rf-browser`) and regenerate; verify a test on the generated text and that `--check` fails when the committed `docs/catalog.md` is stale. +- [x] 2.3 Add a repository test that every library of every active technology plugin names its skills, and that together they cover all of the plugin's skills; verify it passes. + +## 3. Installer (D4) + +- [x] 3.1 Rebuild the bundle and add tests for "Passed through to tools" (`bundle.catalog()` from the dev bundle) and for a `fetch()` snapshot whose fake archive has `skills` in a manifest, plus "Absent field keeps working" (no `skills` key appears); verify `pytest tests/installer` passes. +- [x] 3.2 List `skills` in the README "Python API" table (absent means every skill of the plugin covers the library) and add a line to the 2026.11.0 changelog entry; verify `scripts/check-docs-voice.py installer/README.md installer/CHANGELOG.md` passes. + +## 4. Docs, checks, PR + +- [x] 4.1 Explain the field in CONTRIBUTING.md's `catalog.yaml` step, including that it must change when a skill is added or renamed; verify `scripts/check-docs-voice.py` passes. +- [x] 4.2 Run `pytest tests --ignore=tests/eval`, `scripts/build-catalog.py --check` and `scripts/check-drift.sh`; verify all pass. Done: `tests` 832 passed; `build-catalog.py --check`, `check-drift.sh` and `check-template.sh` pass, except `test_check_rf_environment_uses_project_venv`, which fails locally on a clean `main` too and passes in CI. +- [ ] 4.3 Open a PR against `main` that closes #7 (ask before posting); verify the required checks pass. Opened as #8; ticked once the required checks pass. diff --git a/plugins/rf-api/catalog.yaml b/plugins/rf-api/catalog.yaml index 6278da7..6aad139 100644 --- a/plugins/rf-api/catalog.yaml +++ b/plugins/rf-api/catalog.yaml @@ -10,9 +10,11 @@ libraries: - name: RequestsLibrary package: robotframework-requests versions: ">=0.9.7" + skills: [rf-requests] - name: RESTinstance package: RESTinstance versions: ">=1.8,<2" + skills: [rf-restinstance] robotframework: ">=7.0" agents: - claude-code diff --git a/plugins/rf-desktop/catalog.yaml b/plugins/rf-desktop/catalog.yaml index 9e91ac2..2850cad 100644 --- a/plugins/rf-desktop/catalog.yaml +++ b/plugins/rf-desktop/catalog.yaml @@ -10,6 +10,7 @@ libraries: - name: PlatynUI package: robotframework-PlatynUI versions: ">=0.12.0.dev0" + skills: [rf-platynui] robotframework: ">=7.0" agents: - claude-code diff --git a/plugins/rf-mobile/catalog.yaml b/plugins/rf-mobile/catalog.yaml index 507b83b..44487b2 100644 --- a/plugins/rf-mobile/catalog.yaml +++ b/plugins/rf-mobile/catalog.yaml @@ -10,6 +10,7 @@ libraries: - name: AppiumLibrary package: robotframework-appiumlibrary versions: ">=3,<4" + skills: [rf-appium] robotframework: ">=7.0" agents: - claude-code diff --git a/plugins/rf-web/catalog.yaml b/plugins/rf-web/catalog.yaml index 782edcb..1a9aa3c 100644 --- a/plugins/rf-web/catalog.yaml +++ b/plugins/rf-web/catalog.yaml @@ -10,9 +10,11 @@ libraries: - name: Browser package: robotframework-browser versions: ">=20,<21" + skills: [rf-browser] - name: SeleniumLibrary package: robotframework-seleniumlibrary versions: ">=6.9,<7" + skills: [rf-selenium] robotframework: ">=7.0" agents: - claude-code diff --git a/schemas/catalog.schema.json b/schemas/catalog.schema.json index d3a58c2..18ebcad 100644 --- a/schemas/catalog.schema.json +++ b/schemas/catalog.schema.json @@ -43,7 +43,14 @@ "properties": { "name": {"type": "string"}, "package": {"description": "PyPI package name, used to detect the library in a project.", "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, - "versions": {"description": "Supported version range, PEP 440 specifier.", "type": "string", "minLength": 1} + "versions": {"description": "Supported version range, PEP 440 specifier.", "type": "string", "minLength": 1}, + "skills": { + "description": "Skills of this plugin that cover the library (folder names under skills/). Omit when every skill of the plugin applies.", + "type": "array", + "items": {"type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"}, + "minItems": 1, + "uniqueItems": true + } } } }, diff --git a/scripts/build-catalog.py b/scripts/build-catalog.py index 798678c..feadae7 100644 --- a/scripts/build-catalog.py +++ b/scripts/build-catalog.py @@ -274,6 +274,14 @@ def _libraries(p: Plugin) -> str: return ", ".join(lib["name"] for lib in libs) if libs else "Robot Framework" +def _library_line(lib: dict[str, Any]) -> str: + """``Browser (`robotframework-browser` `>=20,<21`): rf-browser`` in docs/catalog.md.""" + line = f"{lib['name']} (`{lib['package']}` `{lib['versions']}`)" + if lib.get("skills"): + line += ": " + ", ".join(f"`{s}`" for s in lib["skills"]) + return line + + def _maintainers(p: Plugin) -> str: return ", ".join(f"[@{m['github']}](https://github.com/{m['github']})" for m in p.manifest["maintainers"]) @@ -356,7 +364,7 @@ def render_catalog_doc(plugins: list[Plugin], version: str) -> str: out.append(f"- **Robot Framework:** `{m['robotframework']}`") if m["libraries"]: out.append("- **Libraries:**") - out += [f" - {lib['name']} (`{lib['package']}` `{lib['versions']}`)" for lib in m["libraries"]] + out += [f" - {_library_line(lib)}" for lib in m["libraries"]] out.append(f"- **Tested with:** {', '.join(m['agents'])}") out.append(f"- **Maintainers:** {_maintainers(p)}") out.append(f"- **Install (Claude Code):** `/plugin install {p.name}@robotframework-plugins`") @@ -458,6 +466,22 @@ def lifecycle_problems(root: Path, plugins: list[Plugin], market: dict[str, Any] return problems +def library_skill_problems(plugins: list[Plugin]) -> list[str]: + """A ``libraries[].skills`` entry must name a skill folder of the same plugin. + + Checked for every manifest, removed ones too, so a stale entry is caught + before a plugin comes back.""" + problems = [] + for p in plugins: + for lib in p.manifest["libraries"]: + for skill in lib.get("skills", []): + if not (p.path / "skills" / skill / "SKILL.md").is_file(): + problems.append( + f"{p.name}: library {lib['name']} lists skill {skill}, but " + f"plugins/{p.name}/skills/{skill}/SKILL.md doesn't exist") + return problems + + def bundle_problems(plugins: list[Plugin]) -> list[str]: """A listed plugin needs content, or must be a bundle of listed plugins that has none of its own; dependencies name listed plugins.""" @@ -567,7 +591,8 @@ def main(argv: list[str] | None = None) -> int: if args.version_table: print(version_table(installer_version(root), plugins)) return 0 - problems = name_problems(plugins) + symlink_problems(root, plugins) + bundle_problems(plugins) + problems = (name_problems(plugins) + symlink_problems(root, plugins) + bundle_problems(plugins) + + library_skill_problems(plugins)) market = json.loads((root / MARKETPLACE_REL).read_text(encoding="utf-8")) # In --check mode the marketplace on disk must not list a removed plugin; # without it, the file is about to be regenerated. diff --git a/templates/plugin/catalog.yaml b/templates/plugin/catalog.yaml index 8b220cc..d34e65d 100644 --- a/templates/plugin/catalog.yaml +++ b/templates/plugin/catalog.yaml @@ -7,7 +7,12 @@ status: active maintainers: - github: your-github-handle name: Your Name -# The libraries the skills cover, with the versions you checked them against. +# The libraries the skills cover, with the versions you checked them against, +# and the skills of this plugin that cover each one, for example: +# - name: DatabaseLibrary +# package: robotframework-databaselibrary +# versions: ">=2" +# skills: [rf-template] libraries: [] robotframework: ">=7.0" agents: diff --git a/tests/installer/test_bundle_api.py b/tests/installer/test_bundle_api.py index 526417d..24a3788 100644 --- a/tests/installer/test_bundle_api.py +++ b/tests/installer/test_bundle_api.py @@ -41,8 +41,8 @@ def refuse(*a, **k): raise SystemExit("network call") def test_library_matching_data() -> None: web = next(p for p in bundle.catalog() if p["name"] == "rf-web") - assert {"name": "Browser", "package": "robotframework-browser", "versions": web["libraries"][0]["versions"]} \ - in web["libraries"] + browser = next(lib for lib in web["libraries"] if lib["name"] == "Browser") + assert browser["package"] == "robotframework-browser" and browser["versions"] assert web["robotframework"].startswith(">=") for entry in bundle.catalog(): assert {"name", "version", "status", "tier", "summary", "libraries", "robotframework", @@ -146,7 +146,8 @@ def test_api_from_an_installed_wheel(tmp_path: Path) -> None: from robotframework_plugins import bundle assert bundle.CATALOG_FORMAT == 1 and callable(bundle.fetch) dirs = bundle.skills_dirs() - assert list(dirs) == {list(ACTIVE_CONTENT)!r}, list(dirs) + # Other plugins (the template in CI, a community plugin) may be bundled too. + assert list(dirs)[0] == "rf-core" and set(dirs) >= set({list(ACTIVE_CONTENT)!r}), list(dirs) for path in dirs.values(): assert path.is_absolute() and path.is_dir() and any(path.glob("*/SKILL.md")), path assert "site-packages" in str(path), path @@ -157,3 +158,17 @@ def test_api_from_an_installed_wheel(tmp_path: Path) -> None: out = subprocess.run([str(python), "-I", "-c", script], capture_output=True, text=True) assert out.returncode == 0, out.stderr assert out.stdout.strip() == "ok" + + +def test_libraries_name_their_skills() -> None: + """catalog-library-skills: ``libraries[].skills`` reaches tools unchanged (issue #7).""" + by_name = {p["name"]: p for p in bundle.catalog()} + web = {lib["name"]: lib for lib in by_name["rf-web"]["libraries"]} + assert web["Browser"]["skills"] == ["rf-browser"] + assert web["SeleniumLibrary"]["skills"] == ["rf-selenium"] + api = {lib["name"]: lib["skills"] for lib in by_name["rf-api"]["libraries"]} + assert api == {"RequestsLibrary": ["rf-requests"], "RESTinstance": ["rf-restinstance"]} + for name, path in bundle.skills_dirs().items(): + for lib in by_name[name]["libraries"]: + for skill in lib.get("skills", []): + assert (path / skill / "SKILL.md").is_file(), (name, skill) diff --git a/tests/installer/test_bundle_fetch.py b/tests/installer/test_bundle_fetch.py index 76c8462..13d91c0 100644 --- a/tests/installer/test_bundle_fetch.py +++ b/tests/installer/test_bundle_fetch.py @@ -241,3 +241,18 @@ def test_fetch_main_from_github(tmp_path: Path) -> None: assert next(iter(dirs)) == "rf-core" for path in dirs.values(): assert any(path.glob("*/SKILL.md")) + + +def test_snapshot_passes_library_skills_through(github: FakeGitHub, cache: Path) -> None: + """catalog-library-skills: a library with ``skills`` keeps them; one without gets no key.""" + files = _tree() + files["plugins/rf-web/catalog.yaml"] = files["plugins/rf-web/catalog.yaml"].replace( + b"libraries: []\n", + b"libraries:\n" + b" - {name: Browser, package: robotframework-browser, versions: '>=20', skills: [rf-web-skill]}\n" + b" - {name: Other, package: robotframework-other, versions: '>=1'}\n") + github.archives[SHA] = _tarball(files) + web = next(p for p in bundle.fetch("main", cache_dir=cache).catalog() if p["name"] == "rf-web") + libs = {lib["name"]: lib for lib in web["libraries"]} + assert libs["Browser"]["skills"] == ["rf-web-skill"] + assert "skills" not in libs["Other"] diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 4965e83..6e4c253 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -453,3 +453,102 @@ def test_feedback_form_lists_the_listed_plugins(tmp_path: Path) -> None: assert bc.main(["--check", "--root", str(root)]) == 1 assert bc.main(["--root", str(root)]) == 0 assert " - rf-example\n" in form.read_text(encoding="utf-8") + + +# --- libraries[].skills (catalog-library-skills; issue #7) ------------------------------- + + +def _library_variant(tmp_path: Path, extra: str) -> Path: + """The community fixture with ``extra`` lines added to its one library entry.""" + text = (FIXTURES / "valid-community.yaml").read_text(encoding="utf-8").replace( + ' versions: ">=2"\n', ' versions: ">=2"\n' + extra) + path = tmp_path / "variant.yaml" + path.write_text(text, encoding="utf-8") + return path + + +def test_skills_per_library_accepted(scratch: Path, tmp_path: Path) -> None: + _add_plugin(scratch, "rf-example", _library_variant(tmp_path, " skills: [rf-example]\n")) + plugins = bc.load_plugins(scratch) + example = next(p for p in plugins if p.name == "rf-example") + assert example.manifest["libraries"][0]["skills"] == ["rf-example"] + assert bc.library_skill_problems([example]) == [] + + +@pytest.mark.parametrize(("extra", "field"), [ + (" skill: rf-example\n", "skill"), # other unknown keys stay rejected + (" skills: []\n", "skills"), # minItems 1 + (" skills: [rf-example, rf-example]\n", "skills"), # unique + (" skills: [RF_Example]\n", "skills"), # kebab-case +]) +def test_invalid_skills_field_rejected(scratch: Path, tmp_path: Path, extra: str, field: str) -> None: + _add_plugin(scratch, "rf-example", _library_variant(tmp_path, extra)) + with pytest.raises(bc.CatalogError) as exc: + bc.load_plugins(scratch) + assert any("rf-example/catalog.yaml" in p and ("libraries/0" in p) and field in p for p in exc.value.problems), \ + exc.value.problems + + +def test_missing_skill_reported(scratch: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + _add_plugin(scratch, "rf-example", _library_variant(tmp_path, " skills: [rf-example, rf-example-auth]\n")) + example = next(p for p in bc.load_plugins(scratch) if p.name == "rf-example") + assert bc.library_skill_problems([example]) == [ + "rf-example: library DatabaseLibrary lists skill rf-example-auth, but " + "plugins/rf-example/skills/rf-example-auth/SKILL.md doesn't exist"] + + +def test_missing_skill_fails_the_check(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + root = tmp_path / "repo" + shutil.copytree(REPO, root, ignore=shutil.ignore_patterns(".git", ".venv", "node_modules", "__pycache__", + "_assets", "eval", "openspec", "docs")) + (root / "docs").mkdir() + shutil.copy(REPO / "docs" / "catalog.md", root / "docs" / "catalog.md") + manifest = root / "plugins" / "rf-api" / "catalog.yaml" + manifest.write_text(manifest.read_text(encoding="utf-8").replace( + "skills: [rf-requests]", "skills: [rf-requests-auth]"), encoding="utf-8") + assert bc.main(["--check", "--root", str(root)]) == 1 + err = capsys.readouterr().err + assert "rf-api: library RequestsLibrary lists skill rf-requests-auth" in err + + +def test_removed_plugin_with_a_stale_skill_is_reported(scratch: Path, tmp_path: Path) -> None: + text = _library_variant(tmp_path, " skills: [rf-gone]\n").read_text(encoding="utf-8").replace( + "status: active", 'status: removed\nreplacement: rf-core\ndeprecated_since: "2026-01-01"') + path = tmp_path / "removed.yaml" + path.write_text(text, encoding="utf-8") + _add_plugin(scratch, "rf-example", path, content=False) + example = next(p for p in bc.load_plugins(scratch) if p.name == "rf-example") + assert len(bc.library_skill_problems([example])) == 1 + + +def test_catalog_doc_names_the_skills_of_each_library() -> None: + text = (REPO / "docs" / "catalog.md").read_text(encoding="utf-8") + assert " - Browser (`robotframework-browser` `>=20,<21`): `rf-browser`" in text + assert " - SeleniumLibrary (`robotframework-seleniumlibrary` `>=6.9,<7`): `rf-selenium`" in text + assert bc._library_line({"name": "X", "package": "x", "versions": ">=1"}) == "X (`x` `>=1`)" + + +def test_stale_catalog_doc_fails_after_a_skills_change(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + root = tmp_path / "repo" + shutil.copytree(REPO, root, ignore=shutil.ignore_patterns(".git", ".venv", "node_modules", "__pycache__", + "_assets", "eval", "openspec")) + manifest = root / "plugins" / "rf-web" / "catalog.yaml" + manifest.write_text(manifest.read_text(encoding="utf-8").replace( + "skills: [rf-selenium]", "skills: [rf-selenium, rf-browser]"), encoding="utf-8") + assert bc.main(["--check", "--root", str(root)]) == 1 + assert "docs/catalog.md is out of date" in capsys.readouterr().err + + +def test_every_technology_library_names_its_skills() -> None: + """Each library of an active technology plugin names its skills, and + together they cover every skill the plugin ships.""" + for p in bc.load_plugins(REPO): + if p.status != "active" or not p.manifest["libraries"]: + continue + named = set() + for lib in p.manifest["libraries"]: + assert lib.get("skills"), f"{p.name}: {lib['name']} names no skills" + named |= set(lib["skills"]) + shipped = {d.name for d in (p.path / "skills").glob("*") if (d / "SKILL.md").is_file()} + assert named == shipped, p.name + assert bc.library_skill_problems(bc.load_plugins(REPO)) == []