From 9e269aef58158d1b0b31d990310e915814484e42 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Fri, 14 Aug 2026 18:09:00 +0200 Subject: [PATCH] feat(plugin): map client extensions under the dev.openhands namespace Agent Plugins keeps commands, agents, hooks and entry_command out of its portable core, so they belong in a reverse-domain client extension (spec section 8). Claim dev.openhands and read them there: entry_command from extensions["dev.openhands"] in the manifest, and commands/, agents/ and hooks/hooks.json from the top-level dev.openhands/ directory. Their layout inside that directory is unchanged, so a Claude Code plugin ports by moving directories rather than rewriting them. Namespaces we do not implement are ignored without validating their contents, as the spec requires. Extracting the shared readers surfaced that the plugin format carried a private copy of the exported load_agents_from_dir; drop it in favour of the canonical loader, which additionally skips directories, matches .md case-insensitively and skips both README spellings. The commands predicate is aligned with it so the two stay symmetric. Skills discovery has a similar duplicate that is not substitutable today; filed as #4495. AgentPluginsFormat stays out of _FORMATS until the mcp.json loader lands. --- .../openhands/sdk/plugin/format/__init__.py | 9 +- .../sdk/plugin/format/agent_plugins.py | 94 ++++++-- .../openhands/sdk/plugin/format/base.py | 59 +++++ .../sdk/plugin/format/claude_code.py | 57 +---- tests/sdk/plugin/test_agent_plugins_format.py | 214 +++++++++++++++++- 5 files changed, 353 insertions(+), 80 deletions(-) diff --git a/openhands-sdk/openhands/sdk/plugin/format/__init__.py b/openhands-sdk/openhands/sdk/plugin/format/__init__.py index 177bd881e9..0c72fa21a1 100644 --- a/openhands-sdk/openhands/sdk/plugin/format/__init__.py +++ b/openhands-sdk/openhands/sdk/plugin/format/__init__.py @@ -12,7 +12,8 @@ - ``base`` — the abstract :class:`PluginFormat` contract plus the shared discovery logic (skills discovery, final assembly). - ``claude_code`` — the concrete :class:`ClaudeCodePluginFormat` strategy. -- ``agent_plugins`` — the :class:`AgentPluginsFormat` strategy, not registered. +- ``agent_plugins`` — the :class:`AgentPluginsFormat` strategy, not registered; + it also owns our ``dev.openhands`` client-extension namespace. - this package ``__init__`` — the format registry (``_FORMATS``) and the :func:`detect_format` dispatcher. @@ -22,8 +23,8 @@ fallback. :class:`AgentPluginsFormat` is held out of ``_FORMATS`` until its ``mcp.json`` -and client-extension loaders land (#4405): registering it earlier would claim -any directory with a root ``plugin.json`` and load it with zero MCP servers. +loader lands (#4405): registering it earlier would claim any directory with a +root ``plugin.json`` and load it with zero MCP servers. How to add a new plugin format ------------------------------ @@ -61,7 +62,7 @@ # Registered formats, in detection-precedence order. Higher-precedence formats # come first; the Claude Code format is last because it accepts any directory. -# AgentPluginsFormat is intentionally absent until its component loaders land +# AgentPluginsFormat is intentionally absent until its mcp.json loader lands # (see the module docstring); it belongs ahead of ClaudeCodePluginFormat. _FORMATS: list[type[PluginFormat]] = [ClaudeCodePluginFormat] diff --git a/openhands-sdk/openhands/sdk/plugin/format/agent_plugins.py b/openhands-sdk/openhands/sdk/plugin/format/agent_plugins.py index 75b900826f..e78c648ba0 100644 --- a/openhands-sdk/openhands/sdk/plugin/format/agent_plugins.py +++ b/openhands-sdk/openhands/sdk/plugin/format/agent_plugins.py @@ -18,8 +18,13 @@ from openhands.sdk.hooks import HookConfig from openhands.sdk.logger import get_logger from openhands.sdk.mcp.config import MCPServer -from openhands.sdk.plugin.format.base import PluginFormat +from openhands.sdk.plugin.format.base import ( + PluginFormat, + _read_command_definitions, + _read_hooks_config, +) from openhands.sdk.plugin.types import CommandDefinition, PluginManifest +from openhands.sdk.subagent.load import load_agents_from_dir from openhands.sdk.subagent.schema import AgentDefinition @@ -28,6 +33,12 @@ # At the plugin root, not nested as in the Claude Code layout. MANIFEST_FILE: Final[str] = "plugin.json" +#: OpenHands' client-extension namespace (spec §8). Reverse-domain form of +#: openhands.dev, which we control. It names both the ``extensions`` member in +#: the manifest and the top-level extension *directory* holding our file-based +#: components (§8.2). +EXTENSION_NAMESPACE: Final[str] = "dev.openhands" + _SCHEMAS_DIR: Final[Path] = Path(__file__).parent / "schemas" #: The only manifest ``$schema`` we support, and its vendored file. Agent @@ -54,20 +65,21 @@ class AgentPluginsFormat(PluginFormat): ├── plugin.json # required manifest (root-level, closed schema) ├── skills/ # Agent Skills (optional) ├── mcp.json # MCP servers, no leading dot (optional) - └── com.example.client/ # reverse-domain client extensions (optional) + └── dev.openhands/ # our client extension directory (optional) + ├── commands/ + ├── agents/ + └── hooks/hooks.json ``` - Only the manifest and skills are read today (skills via the shared - :meth:`PluginFormat.load_skills`). ``mcp.json`` and OpenHands' client - extension are follow-ups under #4405, so their loaders return empty -- - spec-correct, since a client must ignore extensions it does not implement. + Hooks, commands, agents and ``entry_command`` are not portable core, so they + live in our client extension (§8): file-based components under the top-level + ``dev.openhands/`` directory, and ``entry_command`` under + ``extensions["dev.openhands"]`` in the manifest. Their on-disk layout inside + that directory is unchanged from the Claude Code format, so a plugin ports by + moving those directories rather than rewriting them. Namespaces other than + ours are ignored without validating their contents. - Hooks, commands, agents and ``entry_command`` are not portable core. The - standard's place for them is a client extension: a reverse-domain namespace - carrying manifest data under ``extensions.``, a top-level ``/`` - directory, or both, whose contents the namespace owner defines. Which - namespace we claim and what goes where are still open in #4405, and nothing - here reads one yet. + ``mcp.json`` is a follow-up under #4405, so its loader still returns empty. Not registered in ``_FORMATS`` yet; see the package docstring. """ @@ -91,7 +103,9 @@ def load_manifest(self, plugin_dir: Path) -> PluginManifest: Fields with no ``PluginManifest`` column (``$schema``, ``homepage``, ``repository``, ``license``, ``keywords``, ``extensions``) survive via - ``extra="allow"``. + ``extra="allow"``. Our own extension data is additionally projected onto + the matching ``PluginManifest`` fields, so downstream code reads + ``manifest.entry_command`` regardless of the format it came from. Raises: ValueError: On any fatal manifest violation. @@ -143,20 +157,54 @@ def load_manifest(self, plugin_dir: Path) -> PluginManifest: f"Invalid Agent Plugins manifest {manifest_path}: {e.message}" ) from e - return PluginManifest.model_validate(data) + return PluginManifest.model_validate( + data | _extension_manifest_fields(data, manifest_path) + ) def load_mcp_config(self, plugin_dir: Path) -> dict[str, MCPServer]: # noqa: ARG002 """Not read yet: root ``mcp.json`` is a follow-up.""" return {} - def load_hooks(self, plugin_dir: Path) -> HookConfig | None: # noqa: ARG002 - """Always None: hooks are not part of the Agent Plugins portable core.""" - return None + def load_hooks(self, plugin_dir: Path) -> HookConfig | None: + """Load hooks from ``dev.openhands/hooks/hooks.json``.""" + return _read_hooks_config(plugin_dir / EXTENSION_NAMESPACE) + + def load_agents(self, plugin_dir: Path) -> list[AgentDefinition]: + """Load agent definitions from ``dev.openhands/agents/``.""" + return load_agents_from_dir(plugin_dir / EXTENSION_NAMESPACE / "agents") + + def load_commands(self, plugin_dir: Path) -> list[CommandDefinition]: + """Load command definitions from ``dev.openhands/commands/``.""" + return _read_command_definitions(plugin_dir / EXTENSION_NAMESPACE) + - def load_agents(self, plugin_dir: Path) -> list[AgentDefinition]: # noqa: ARG002 - """Always empty: agents are not part of the Agent Plugins portable core.""" - return [] +def _extension_manifest_fields( + data: dict[str, Any], manifest_path: Path +) -> dict[str, Any]: + """Map our namespace's manifest data onto ``PluginManifest`` fields. + + Only ``extensions["dev.openhands"]`` is read. Other namespaces are ignored + without inspecting their contents, as §8.1 requires -- a foreign namespace + carrying an ``entry_command`` of the wrong type is none of our business. + + Within our own namespace we own the validation, and apply the same narrow + failure boundary as elsewhere: an unusable value is reported and ignored + rather than rejecting the plugin. + """ + # The manifest has passed validation, so ``extensions`` -- if still present + # -- is an object whose values are objects. + extension = data.get("extensions", {}).get(EXTENSION_NAMESPACE) + if not extension: + return {} - def load_commands(self, plugin_dir: Path) -> list[CommandDefinition]: # noqa: ARG002 - """Always empty: commands are not part of the Agent Plugins portable core.""" - return [] + entry_command = extension.get("entry_command") + if entry_command is not None and not isinstance(entry_command, str): + logger.warning( + "Ignoring non-string '%s' entry_command in %s: %r", + EXTENSION_NAMESPACE, + manifest_path, + entry_command, + ) + entry_command = None + + return {"entry_command": entry_command} if entry_command else {} diff --git a/openhands-sdk/openhands/sdk/plugin/format/base.py b/openhands-sdk/openhands/sdk/plugin/format/base.py index 2d720965ab..dc2b88047c 100644 --- a/openhands-sdk/openhands/sdk/plugin/format/base.py +++ b/openhands-sdk/openhands/sdk/plugin/format/base.py @@ -144,6 +144,65 @@ def load(self, plugin_dir: Path) -> Plugin: ) +def _read_hooks_config(root: Path) -> HookConfig | None: + """Read ``hooks/hooks.json`` under ``root``, or None if it is absent. + + Shared by the concrete strategies: hooks, agents and commands use the same + on-disk layout in both formats — only the directory they are rooted at + differs (the plugin root for Claude Code, the client-extension directory for + Agent Plugins). + """ + hooks_json = root / "hooks" / "hooks.json" + if not hooks_json.exists(): + return None + + try: + hook_config = HookConfig.load(path=hooks_json) + # A hooks.json that parses but declares no hooks yields an empty config. + # Keep that distinct from "file not present" (None). An unparseable or + # schema-invalid file raises out of load() and is caught below. + if hook_config.is_empty(): + logger.info(f"No hooks configured in {hooks_json}") + return HookConfig() + logger.info(f"Loaded hooks from {hooks_json}") + return hook_config + except Exception as e: + logger.warning(f"Failed to load hooks from {hooks_json}: {e}") + return None + + +def _read_command_definitions(root: Path) -> list[CommandDefinition]: + """Read command definitions from the ``commands/`` directory under ``root``. + + Commands have no counterpart to :func:`load_agents_from_dir`, so this is the + one loader of the three the plugin format still owns. It applies the same + file predicate, so ``commands/`` and ``agents/`` stay symmetric. + """ + commands_dir = root / "commands" + if not commands_dir.is_dir(): + return [] + + commands: list[CommandDefinition] = [] + for item in sorted(commands_dir.iterdir()): + if ( + not item.is_dir() + and item.suffix.lower() == ".md" + and item.name + not in ( + "README.md", + "readme.md", + ) + ): + try: + command = CommandDefinition.load(item) + commands.append(command) + logger.debug(f"Loaded command: {command.name} from {item}") + except Exception as e: + logger.warning(f"Failed to load command from {item}: {e}") + + return commands + + def _load_skills_from_skills_dir(skills_dir: Path) -> list[Skill]: """Load every skill under a plugin's ``skills/`` directory.""" skills: list[Skill] = [] diff --git a/openhands-sdk/openhands/sdk/plugin/format/claude_code.py b/openhands-sdk/openhands/sdk/plugin/format/claude_code.py index 2b1844f319..a188b93a2d 100644 --- a/openhands-sdk/openhands/sdk/plugin/format/claude_code.py +++ b/openhands-sdk/openhands/sdk/plugin/format/claude_code.py @@ -18,13 +18,18 @@ from openhands.sdk.hooks import HookConfig from openhands.sdk.logger import get_logger from openhands.sdk.mcp.config import MCPServer, coerce_mcp_config -from openhands.sdk.plugin.format.base import PluginFormat +from openhands.sdk.plugin.format.base import ( + PluginFormat, + _read_command_definitions, + _read_hooks_config, +) from openhands.sdk.plugin.types import ( CommandDefinition, PluginAuthor, PluginManifest, ) from openhands.sdk.skills.utils import load_mcp_config +from openhands.sdk.subagent.load import load_agents_from_dir from openhands.sdk.subagent.schema import AgentDefinition @@ -140,56 +145,12 @@ def load_mcp_config(self, plugin_dir: Path) -> dict[str, MCPServer]: def load_hooks(self, plugin_dir: Path) -> HookConfig | None: """Load hooks configuration from ``hooks/hooks.json``.""" - hooks_json = plugin_dir / "hooks" / "hooks.json" - if not hooks_json.exists(): - return None - - try: - hook_config = HookConfig.load(path=hooks_json) - # If hooks.json exists but is invalid, HookConfig.load() returns an - # empty config and logs the validation error. Keep that distinct from - # "file not present" (None). - if hook_config.is_empty(): - logger.info(f"No hooks configured in {hooks_json}") - return HookConfig() - logger.info(f"Loaded hooks from {hooks_json}") - return hook_config - except Exception as e: - logger.warning(f"Failed to load hooks from {hooks_json}: {e}") - return None + return _read_hooks_config(plugin_dir) def load_agents(self, plugin_dir: Path) -> list[AgentDefinition]: """Load agent definitions from the ``agents/`` directory.""" - agents_dir = plugin_dir / "agents" - if not agents_dir.is_dir(): - return [] - - agents: list[AgentDefinition] = [] - for item in sorted(agents_dir.iterdir()): - if item.suffix == ".md" and item.name.lower() != "readme.md": - try: - agent = AgentDefinition.load(item) - agents.append(agent) - logger.debug(f"Loaded agent: {agent.name} from {item}") - except Exception as e: - logger.warning(f"Failed to load agent from {item}: {e}") - - return agents + return load_agents_from_dir(plugin_dir / "agents") def load_commands(self, plugin_dir: Path) -> list[CommandDefinition]: """Load command definitions from the ``commands/`` directory.""" - commands_dir = plugin_dir / "commands" - if not commands_dir.is_dir(): - return [] - - commands: list[CommandDefinition] = [] - for item in sorted(commands_dir.iterdir()): - if item.suffix == ".md" and item.name.lower() != "readme.md": - try: - command = CommandDefinition.load(item) - commands.append(command) - logger.debug(f"Loaded command: {command.name} from {item}") - except Exception as e: - logger.warning(f"Failed to load command from {item}: {e}") - - return commands + return _read_command_definitions(plugin_dir) diff --git a/tests/sdk/plugin/test_agent_plugins_format.py b/tests/sdk/plugin/test_agent_plugins_format.py index 2d1409a413..9835144db1 100644 --- a/tests/sdk/plugin/test_agent_plugins_format.py +++ b/tests/sdk/plugin/test_agent_plugins_format.py @@ -1,4 +1,4 @@ -"""Tests for the Agent Plugins format: detection and the manifest loader.""" +"""Tests for the Agent Plugins format: detection, manifest, and components.""" import json from pathlib import Path @@ -13,6 +13,7 @@ ) from openhands.sdk.plugin.format.agent_plugins import ( _MANIFEST_SCHEMA_FILE, + EXTENSION_NAMESPACE, MANIFEST_SCHEMA_URL, _load_schema, ) @@ -45,6 +46,36 @@ def write_manifest(plugin_dir: Path, manifest: dict | str) -> Path: return path +def write_extension_components(root: Path) -> None: + """Write one hook, agent and command under ``root``, in the shared layout.""" + (root / "hooks").mkdir(parents=True, exist_ok=True) + (root / "hooks" / "hooks.json").write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + { + "matcher": "*", + "hooks": [{"type": "command", "command": "echo hi"}], + } + ] + } + } + ), + encoding="utf-8", + ) + (root / "agents").mkdir(parents=True, exist_ok=True) + (root / "agents" / "helper.md").write_text( + "---\nname: helper\ndescription: A helper agent.\n---\n\nHelp out.\n", + encoding="utf-8", + ) + (root / "commands").mkdir(parents=True, exist_ok=True) + (root / "commands" / "now.md").write_text( + "---\ndescription: Report the weather now.\n---\n\nReport it.\n", + encoding="utf-8", + ) + + class TestDetect: """Detection is presence-only: a root plugin.json claims the directory.""" @@ -353,7 +384,7 @@ def test_manifest_is_a_directory(self, tmp_path: Path): class TestComponentLoaders: - """Skills are shared with the base; the rest are follow-ups.""" + """Portable core: skills are shared with the base, mcp.json is a follow-up.""" def test_load_assembles_plugin_with_skills(self, tmp_path: Path): write_manifest(tmp_path, EXAMPLE_MANIFEST) @@ -369,21 +400,194 @@ def test_load_assembles_plugin_with_skills(self, tmp_path: Path): assert plugin.manifest.name == "agent-plugins-example" assert [s.name for s in plugin.skills] == ["summarize"] - def test_deferred_component_loaders_are_empty(self, tmp_path: Path): - """mcp.json and client extensions are not read yet.""" + def test_mcp_loader_is_deferred(self, tmp_path: Path): + """mcp.json is not read yet.""" write_manifest(tmp_path, EXAMPLE_MANIFEST) (tmp_path / "mcp.json").write_text( json.dumps({"mcpServers": {"x": {"type": "stdio", "command": "echo"}}}), encoding="utf-8", ) + assert AgentPluginsFormat().load_mcp_config(tmp_path) == {} + + +class TestClientExtension: + """Our ``dev.openhands`` namespace: manifest data and extension directory.""" + + def test_entry_command_is_mapped_from_our_namespace(self, tmp_path: Path): + write_manifest( + tmp_path, + { + **EXAMPLE_MANIFEST, + "extensions": {EXTENSION_NAMESPACE: {"entry_command": "now"}}, + }, + ) + + manifest = AgentPluginsFormat().load_manifest(tmp_path) + + assert manifest.entry_command == "now" + # The raw extension data is preserved alongside the mapped field. + assert manifest.model_dump()["extensions"] == { + EXTENSION_NAMESPACE: {"entry_command": "now"} + } + + @pytest.mark.parametrize( + "value", + [ + pytest.param(42, id="int"), + pytest.param([], id="list"), + pytest.param({"a": 1}, id="object"), + pytest.param(True, id="bool"), + ], + ) + def test_non_string_entry_command_is_reported_and_ignored( + self, tmp_path: Path, caplog, value + ): + """Our namespace, our rules: a bad value drops the field, not the plugin.""" + write_manifest( + tmp_path, + { + **EXAMPLE_MANIFEST, + "extensions": {EXTENSION_NAMESPACE: {"entry_command": value}}, + }, + ) + + manifest = AgentPluginsFormat().load_manifest(tmp_path) + + assert manifest.entry_command is None + assert "Ignoring non-string" in caplog.text + + @pytest.mark.parametrize( + "extensions", + [ + pytest.param({}, id="no-namespaces"), + pytest.param({EXTENSION_NAMESPACE: {}}, id="ours-empty"), + pytest.param({EXTENSION_NAMESPACE: {"entry_command": None}}, id="null"), + pytest.param({EXTENSION_NAMESPACE: {"entry_command": ""}}, id="empty-str"), + # §8.1: a foreign namespace's contents are never validated, so its + # entry_command -- of any type -- is not ours to read. + pytest.param( + {"com.example.client": {"entry_command": 42}}, id="foreign-namespace" + ), + ], + ) + def test_no_usable_entry_command(self, tmp_path: Path, extensions): + write_manifest(tmp_path, {**EXAMPLE_MANIFEST, "extensions": extensions}) + + assert AgentPluginsFormat().load_manifest(tmp_path).entry_command is None + + def test_components_load_from_the_extension_directory(self, tmp_path: Path): + write_manifest(tmp_path, EXAMPLE_MANIFEST) + write_extension_components(tmp_path / EXTENSION_NAMESPACE) + + fmt = AgentPluginsFormat() + + hooks = fmt.load_hooks(tmp_path) + assert hooks is not None and len(hooks.pre_tool_use) == 1 + assert [a.name for a in fmt.load_agents(tmp_path)] == ["helper"] + assert [c.name for c in fmt.load_commands(tmp_path)] == ["now"] + + @pytest.mark.parametrize( + "location", + [ + pytest.param(None, id="nowhere"), + pytest.param(".", id="plugin-root"), + pytest.param("com.example.client", id="foreign-namespace"), + ], + ) + def test_components_outside_our_namespace_are_not_read( + self, tmp_path: Path, location: str | None + ): + """§8.2: only the owner of a namespace reads its directory. + + The plugin root holds portable core, and another vendor's directory is + theirs alone -- components in either place are not ours to load. + """ + write_manifest(tmp_path, EXAMPLE_MANIFEST) + if location is not None: + write_extension_components(tmp_path / location) + fmt = AgentPluginsFormat() - assert fmt.load_mcp_config(tmp_path) == {} assert fmt.load_hooks(tmp_path) is None assert fmt.load_agents(tmp_path) == [] assert fmt.load_commands(tmp_path) == [] + def test_skills_stay_portable_core(self, tmp_path: Path): + """Skills come from ``/skills``, never from the extension dir.""" + write_manifest(tmp_path, EXAMPLE_MANIFEST) + skill_dir = tmp_path / EXTENSION_NAMESPACE / "skills" / "summarize" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: summarize\ndescription: Summarize text.\n---\n\nDo it.\n", + encoding="utf-8", + ) + + assert AgentPluginsFormat().load(tmp_path).skills == [] + + def test_broken_components_do_not_sink_the_plugin(self, tmp_path: Path, caplog): + """§4.1: the failure boundary is one entry, not the plugin. + + A broken agent/command is skipped while its siblings load, and an + unparseable hooks.json only costs the plugin its hooks. + """ + write_manifest(tmp_path, EXAMPLE_MANIFEST) + extension_dir = tmp_path / EXTENSION_NAMESPACE + write_extension_components(extension_dir) + (extension_dir / "hooks" / "hooks.json").write_text( + '{"hooks": { broken }', encoding="utf-8" + ) + # Unparseable YAML frontmatter: the one failure both loaders share. + broken_frontmatter = "---\nname: [unclosed\n---\n\nbody\n" + (extension_dir / "agents" / "broken.md").write_text( + broken_frontmatter, encoding="utf-8" + ) + (extension_dir / "commands" / "broken.md").write_text( + broken_frontmatter, encoding="utf-8" + ) + + plugin = AgentPluginsFormat().load(tmp_path) + + assert plugin.manifest.name == "agent-plugins-example" + assert [a.name for a in plugin.agents] == ["helper"] + assert [c.name for c in plugin.commands] == ["now"] + assert plugin.hooks is None + assert "Failed to load hooks" in caplog.text + assert "Failed to load agent" in caplog.text + assert "Failed to load command" in caplog.text + + def test_hooks_json_without_hooks_is_empty_not_missing(self, tmp_path: Path): + """An empty-but-valid hooks.json is distinct from an absent one.""" + write_manifest(tmp_path, EXAMPLE_MANIFEST) + hooks_json = tmp_path / EXTENSION_NAMESPACE / "hooks" / "hooks.json" + hooks_json.parent.mkdir(parents=True) + hooks_json.write_text('{"hooks": {}}', encoding="utf-8") + + hooks = AgentPluginsFormat().load_hooks(tmp_path) + + assert hooks is not None + assert hooks.is_empty() + + def test_load_assembles_the_full_extension(self, tmp_path: Path): + write_manifest( + tmp_path, + { + **EXAMPLE_MANIFEST, + "extensions": { + EXTENSION_NAMESPACE: {"entry_command": "now"}, + "com.example.client": {"whatever": True}, + }, + }, + ) + write_extension_components(tmp_path / EXTENSION_NAMESPACE) + + plugin = AgentPluginsFormat().load(tmp_path) + + assert plugin.entry_slash_command == "/agent-plugins-example:now" + assert plugin.hooks is not None + assert [a.name for a in plugin.agents] == ["helper"] + assert [c.name for c in plugin.commands] == ["now"] + class TestVendoredSchema: """The vendored file must stay consistent with the URL we accept."""