From e57ebc4c18d81c69dcad87272f5b1ac5c423ea3d Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Mon, 10 Aug 2026 16:10:15 -0500 Subject: [PATCH 1/2] feat: distribute agent plugin migration skill --- README.md | 14 +++ skills/migrate-agent-plugin/SKILL.md | 68 +++++++++++ .../references/client-extensions.md | 76 ++++++++++++ .../references/migration-guide.md | 114 ++++++++++++++++++ .../references/validation-checklist.md | 49 ++++++++ tests/test_skills.py | 35 ++++++ 6 files changed, 356 insertions(+) create mode 100644 skills/migrate-agent-plugin/SKILL.md create mode 100644 skills/migrate-agent-plugin/references/client-extensions.md create mode 100644 skills/migrate-agent-plugin/references/migration-guide.md create mode 100644 skills/migrate-agent-plugin/references/validation-checklist.md create mode 100644 tests/test_skills.py diff --git a/README.md b/README.md index 8d886bc..8a73fcf 100644 --- a/README.md +++ b/README.md @@ -515,6 +515,20 @@ The kit never logs handler result payloads. Keys containing `token`, `secret`, `password`, `passwd`, `api_key`, `apikey`, or `auth` are replaced with `***` at any nesting depth before arguments are logged. +## Agent skills + +Repository-owned skills are consumable directly from [`skills/`](skills). To +make the Agent Plugins migration skill available to Codex while keeping this +repository as the source of truth: + +```bash +ln -s "$(pwd)/skills/migrate-agent-plugin" ~/.codex/skills/migrate-agent-plugin +``` + +Remove or rename an existing destination before creating the link. The skill +includes its migration guide, client-extension rules, and validation checklist, +so the linked directory is self-contained. + ## Development Uses [uv](https://docs.astral.sh/uv/). Install it with `brew install uv` (macOS) or diff --git a/skills/migrate-agent-plugin/SKILL.md b/skills/migrate-agent-plugin/SKILL.md new file mode 100644 index 0000000..7fa96b5 --- /dev/null +++ b/skills/migrate-agent-plugin/SKILL.md @@ -0,0 +1,68 @@ +--- +name: migrate-agent-plugin +description: Migrate an existing Claude, Copilot, Codex, Cursor, Kiro, VS Code, or other client-specific agent plugin to the portable Agent Plugins v1 structure while preserving platform-specific hooks, agents, commands, LSP, UI, and marketplace behavior. Use when auditing, converting, or modernizing an agent plugin. +license: MIT +metadata: + version: "1.0.0" +--- + +# Migrate an Agent Plugin + +Convert an existing plugin to the Agent Plugins v1 portable core without prematurely removing behavior required by its current clients. + +## Source of truth + +Use the current [Agent Plugins specification](https://agent-plugins.org/specification) as the normative source. + +Read these references before editing: + +- [Migration guide](references/migration-guide.md) +- [Client extensions](references/client-extensions.md) +- [Validation checklist](references/validation-checklist.md) + +## Workflow + +1. Inventory the current plugin before moving files. + - Record every manifest, skill, prompt or command, agent, MCP server, hook, LSP server, UI resource, script, secret requirement, and marketplace entry. + - Identify the clients that currently load each artifact and the install paths or discovery rules they require. + - Run existing tests or capture a manual smoke-test baseline. + +2. Classify each artifact. + - Portable core: root `plugin.json`, Agent Skills in `skills/`, and MCP servers in root `mcp.json`. + - Client extension: additional behavior loaded through a reverse-domain namespace owned and documented by a client. + - Compatibility layer: legacy files or a generated client package retained until that client supports the portable or namespaced form. + - Distribution metadata: marketplace catalogs, install policy, signing, and release configuration; these are outside the portable package format. + +3. Add the portable manifest. + - Create `plugin.json` at the plugin root. + - Set `$schema` to `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`. + - Include `name` and only supported metadata fields. + - Do not put component paths or client fields such as `hooks`, `agents`, `skills`, or `mcpServers` at the top level. + +4. Normalize portable components. + - Put each skill at `skills//SKILL.md`; only immediate children of `skills/` are discovered. + - Make each skill name match its parent directory and the Agent Skills naming rules. + - If MCP is present, convert it to root `mcp.json`, declare the matching v1.0.0 schema, and give every server an explicit `stdio`, `streamable-http`, or `sse` type. + - Use `${PLUGIN_ROOT}` for packaged read-only resources and `${PLUGIN_DATA}` for persistent writable state where the MCP schema permits expansion. + +5. Preserve non-core behavior. + - Use a client extension only when the target client publishes a reverse-domain namespace and its semantics. + - If the client still requires a legacy layout, keep or generate a separate compatibility package. Treat the portable files as the source of truth and avoid manually maintaining divergent copies. + - Do not invent a vendor namespace and assume an unrelated client will load it. + +6. Validate and test incrementally. + - Validate the portable manifest, every skill, optional MCP configuration, and package path containment. + - Test each supported client independently, including hooks and other compatibility behavior. + - Remove legacy artifacts only after the replacement passes the same behavior checks. + +## Required migration report + +Before finishing, report: + +- The discovered source format and target clients. +- A mapping from every original artifact to portable core, extension, compatibility layer, distribution metadata, or removal. +- Files added, moved, generated, retained, and intentionally omitted. +- Validation and client smoke-test results. +- Remaining client-specific risks or manual steps. + +Prefer an additive, reversible migration. Never claim that hooks, agents, commands, LSP servers, UI, or marketplace metadata became portable Agent Plugins v1 components. diff --git a/skills/migrate-agent-plugin/references/client-extensions.md b/skills/migrate-agent-plugin/references/client-extensions.md new file mode 100644 index 0000000..1284ab6 --- /dev/null +++ b/skills/migrate-agent-plugin/references/client-extensions.md @@ -0,0 +1,76 @@ +# Client Extensions + +Agent Plugins v1 keeps the portable core small. Client extensions provide an escape hatch for hooks, agents, commands, LSP, UI, and other behavior that has not become portable. + +## Rules + +1. Extension namespaces are reverse-domain identifiers, such as `com.vendor.client`. +2. The client that owns the namespace defines its fields, files, validation, and runtime behavior. +3. Manifest extension data belongs under `extensions` in root `plugin.json`. +4. Extension files belong in a top-level directory whose name exactly matches the namespace. +5. Other clients ignore namespaces they do not implement without losing valid portable components. +6. An extension is not a way for a plugin author to make up fields that existing clients will automatically understand. + +## Manifest data + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "your-plugin", + "extensions": { + "com.vendor.client": { + "settingDefinedByThatClient": true + } + } +} +``` + +The portable specification validates only that each namespace value is an object. The owning client defines everything inside it. + +## Extension files + +```text +your-plugin/ +├── plugin.json +├── skills/ +└── com.vendor.client/ + ├── hooks/ + │ └── hooks.json + └── agents/ + └── reviewer.md +``` + +This layout has effect only if `com.vendor.client` actually implements those paths. + +## Choose the right compatibility strategy + +### The client documents an Agent Plugins extension namespace + +Use its exact namespace, fields, and directory layout. Test failures in the extension separately from the portable skills and MCP configuration. + +### The client supports Agent Plugins core but still discovers legacy add-ons + +Keep the root manifest conforming. Follow the client's documented additive loading behavior for legacy hooks or agents, and label those files as client-specific. If that layout conflicts with strict portable packaging, generate a separate client distribution from the portable source. + +### The client does not support Agent Plugins core + +Keep the legacy plugin working and add a portable sibling package. Share underlying skill text, scripts, and server code where safe, but avoid symlinks that resolve outside either package root. + +## Hooks + +Hooks are a common extension candidate, but Agent Plugins v1 does not define their event names, input/output protocol, command format, security model, or discovery path. Preserve the existing hook until the target client documents a replacement. Review hook scripts as executable code and test approval, denial, failure, and timeout behavior after migration. + +## Do not put client fields at the manifest top level + +These examples are nonconforming in an Agent Plugins v1 root manifest: + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "your-plugin", + "hooks": "hooks.json", + "agents": "agents/" +} +``` + +Use a documented extension or a compatibility package instead. diff --git a/skills/migrate-agent-plugin/references/migration-guide.md b/skills/migrate-agent-plugin/references/migration-guide.md new file mode 100644 index 0000000..1186a34 --- /dev/null +++ b/skills/migrate-agent-plugin/references/migration-guide.md @@ -0,0 +1,114 @@ +# Migration Guide + +Use this guide to map an existing plugin into the Agent Plugins v1 portable core while keeping client-specific behavior available. + +## 1. Inventory before conversion + +Locate all plugin manifests and component roots. Common legacy or client-specific artifacts include: + +- `.claude-plugin/plugin.json`, `.plugin/plugin.json`, `.github/plugin/plugin.json`, `.codex-plugin/plugin.json`, or a root manifest without the Agent Plugins `$schema`. +- Skills under `skills/`, `.agents/skills/`, `.github/skills/`, `.claude/skills/`, or a configured custom path. +- MCP configuration in `.mcp.json`, `.github/mcp.json`, another client config, or inline manifest fields. +- Hooks in `hooks.json`, `hooks/hooks.json`, a settings file, or inline manifest fields. +- Commands, prompts, custom agents, LSP servers, UI assets, authentication declarations, and marketplace catalogs. + +Do not delete or move anything until its consumer and replacement are known. + +## 2. Map every artifact + +| Existing artifact | Agent Plugins v1 destination | Compatibility action | +| --- | --- | --- | +| Plugin identity and metadata | Root `plugin.json` | Retain a legacy manifest only if a target client still requires it. Generate copies from one source when possible. | +| Reusable skill | `skills//SKILL.md` | Normalize frontmatter and keep scripts, references, and assets inside the skill directory. | +| MCP server | Root `mcp.json` | Convert client-specific fields and declare an explicit transport type. Keep a client adapter only for unsupported fields or transports. | +| Hook | No portable v1 destination | Use a client-owned extension namespace or retain a client compatibility package. | +| Custom agent or persona | No portable v1 destination | Keep it in a client extension or compatibility package. Convert to a skill only when on-demand instructions truly preserve its semantics. | +| Command or prompt | No portable v1 destination | Convert reusable task instructions to a skill when appropriate; otherwise retain the client feature. | +| LSP server | No portable v1 destination | Retain it as a client extension or compatibility package. | +| UI or app integration | No portable v1 destination | Retain it as a client extension or compatibility package. | +| Marketplace entry, install policy, signing | Outside the portable package | Keep it in the platform's distribution repository or release process. | + +## 3. Create the portable manifest + +Start with the smallest valid root manifest: + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "your-plugin" +} +``` + +Allowed optional fields are `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords`, and `extensions`. The schema is closed. Unknown top-level fields are nonconforming even when a particular client historically accepted them. + +Plugin names are 1–64 characters, use lowercase ASCII letters, digits, hyphens, and periods, begin and end with an alphanumeric character, and contain neither `--` nor `..`. + +## 4. Normalize skills + +Each discoverable skill must be an immediate child of `skills/`: + +```text +skills/ +└── deploy/ + ├── SKILL.md + ├── scripts/ + ├── references/ + └── assets/ +``` + +The `SKILL.md` name must match its parent directory. Keep skill-relative dependencies inside that directory and update references after moving files. Do not rely on recursive discovery of nested skill directories. + +## 5. Convert MCP configuration + +Portable MCP configuration belongs in root `mcp.json`, not inline in `plugin.json`: + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "example": { + "type": "stdio", + "command": "node", + "args": ["${PLUGIN_ROOT}/server/index.js"], + "cwd": "${PLUGIN_ROOT}" + } + } +} +``` + +Use a single executable token for `command`; do not put a shell command line in that field. A bundled executable uses a plugin-relative `./path`. Non-loopback remote servers use HTTPS. Do not embed secrets in remote headers. + +## 6. Preserve platform behavior + +Migration should be additive first: + +1. Add the portable root manifest and components. +2. Leave the working client package intact. +3. Make portable files the source of truth. +4. Generate or copy legacy adapters only when client documentation requires them. +5. Test every supported client. +6. Remove old files only after their consumers have migrated. + +A repository can keep the portable plugin and client adapters as siblings: + +```text +repository/ +├── plugin/ # Agent Plugins v1 portable package +└── client-adapters/ # Generated or maintained platform packages + ├── client-a/ + └── client-b/ +``` + +An adapter is not part of the portable core. Clearly label which files are canonical and automate synchronization when multiple manifests or layouts must ship. + +## 7. Test the migration + +Test at least: + +- Loading the plugin with a conforming Agent Plugins client. +- Skill discovery and activation. +- Every MCP transport and tool, when present. +- Legacy installation and all retained hooks, agents, commands, LSP, or UI behavior. +- Upgrade and rollback from the last released client-specific package. + +The migration is complete only when the portable core validates and the promised client behaviors still work. diff --git a/skills/migrate-agent-plugin/references/validation-checklist.md b/skills/migrate-agent-plugin/references/validation-checklist.md new file mode 100644 index 0000000..9be4420 --- /dev/null +++ b/skills/migrate-agent-plugin/references/validation-checklist.md @@ -0,0 +1,49 @@ +# Validation Checklist + +## Package + +- [ ] `plugin.json` is a regular file at the plugin root. +- [ ] Every packaged or resolved path remains inside the plugin root. +- [ ] Symlinks, junctions, and reparse points do not escape the package. +- [ ] No credentials, tokens, or private keys are embedded in the package. + +## Manifest + +- [ ] `$schema` is `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`. +- [ ] `name` satisfies the v1 length and character rules. +- [ ] Only `$schema`, `name`, `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords`, and `extensions` appear at the top level. +- [ ] Every `extensions` member is an object keyed by a reverse-domain namespace. +- [ ] Optional metadata has the type required by the schema. + +## Skills + +- [ ] Each skill is an immediate child directory of `skills/`. +- [ ] Each skill contains a regular file named exactly `SKILL.md`. +- [ ] The frontmatter `name` matches the directory name and Agent Skills naming rules. +- [ ] `description` explains both what the skill does and when to use it. +- [ ] Referenced scripts, references, and assets exist within the skill directory. +- [ ] Each skill validates independently; one invalid skill should not hide failures in another. + +## MCP, when present + +- [ ] Root `mcp.json` uses `https://agent-plugins.org/schemas/1.0.0/mcp.schema.json`. +- [ ] Its specification version matches `plugin.json`. +- [ ] Every server declares exactly one supported transport variant. +- [ ] `command` is one executable token, not a shell command string. +- [ ] Plugin-relative executable paths and working directories begin with `./`. +- [ ] `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` appear only in fields where expansion is defined. +- [ ] Remote non-loopback URLs use HTTPS and contain no embedded credentials. + +## Client compatibility + +- [ ] Every client extension uses a namespace implemented and documented by its owning client. +- [ ] Hooks, agents, commands, LSP, UI, and marketplace metadata are not presented as portable v1 components. +- [ ] A compatibility package remains available for clients that still require a legacy layout. +- [ ] Portable and legacy manifests are generated from one metadata source where practical. +- [ ] Installation, update, rollback, and behavior smoke tests pass for every supported client. + +## Handoff + +- [ ] The migration report maps every original artifact to its new owner and location. +- [ ] Removed files have a verified replacement and recovery path. +- [ ] Remaining limitations and manual release steps are documented. diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..cb2c61f --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).parents[1] +SKILLS = ROOT / "skills" + + +class SkillDistributionTests(unittest.TestCase): + def test_skills_are_self_contained_and_named_for_their_directory(self) -> None: + skill_files = sorted(SKILLS.glob("*/SKILL.md")) + self.assertTrue(skill_files, "Expected at least one consumable skill") + + for skill_file in skill_files: + text = skill_file.read_text() + self.assertTrue(text.startswith("---\n"), skill_file) + _, frontmatter, body = text.split("---", 2) + metadata = yaml.safe_load(frontmatter) + self.assertEqual(skill_file.parent.name, metadata["name"]) + self.assertTrue(metadata["description"]) + + for target in re.findall(r"\[[^]]+\]\(([^)]+)\)", body): + if "://" in target: + continue + resolved = skill_file.parent / target.split("#", 1)[0] + self.assertTrue(resolved.is_file(), f"Missing {target} from {skill_file}") + + +if __name__ == "__main__": + unittest.main() From 08eb312ec758ce91b44bf3923f9243f460db6930 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Mon, 10 Aug 2026 16:40:51 -0500 Subject: [PATCH 2/2] feat: support specialized Hermes plugin contracts --- AGENTS.md | 3 + README.md | 54 +++++++++ hermes_plugin_kit/__init__.py | 217 +++++++++++++++++++++++++++++++++- pyproject.toml | 8 +- tests/test_hermes_contract.py | 34 +++++- tests/test_kit.py | 118 ++++++++++++++++-- uv.lock | 6 +- 7 files changed, 414 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 01fc035..c92f644 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,9 @@ plugin. dictionaries unless deliberately returning an already-encoded string. - Keep validation errors instructive for model-facing callers, including the missing argument name and example when available. +- Keep stateful Hermes provider ABCs as provider instances: register memory, + image-generation, and video-generation providers through their specialized + contexts instead of decorating provider methods as general plugin surfaces. - Redact secret-looking values in logs and avoid logging full untrusted payloads. - Use `uv` and the Makefile for local development: `make install`, `make test`, `make test-one T=tests.test_kit.SchemaConventionTests`, diff --git a/README.md b/README.md index 8a73fcf..a3f4905 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,60 @@ deliver_media( ) ``` +`plugin_skill` reads and validates the referenced file immediately, then checks +it again during registration. Every required skill must contain closed YAML +frontmatter, a non-empty body, matching `name` and `description` values, and +well-shaped Hermes metadata. Optional missing files remain skippable; if an +optional file exists, it must satisfy the same contract. + +```yaml +--- +name: temporal-awareness +description: Calibrate responses against local time and message gaps. +platforms: [macos, linux] +metadata: + hermes: + tags: [Time, Context] + requires_toolsets: [terminal] +--- +``` + +The validator covers Hermes platform, conditional activation, config, +blueprint, environment-variable, and credential-file metadata shapes. Runtime +activation and setup behavior remain owned by Hermes Agent. + +## Subagents and specialized providers + +Subagent lifecycle supervision is host-owned. Use the checked accessor instead +of importing delegation internals: + +```python +from agent.subagent_lifecycle import SubagentLaunchRequest +from hermes_plugin_kit import get_subagent_lifecycle + +service = get_subagent_lifecycle(ctx) +handle = service.launch(SubagentLaunchRequest(goal="Review this change.")) +``` + +Memory, image-generation, and video-generation providers remain instances of +their Hermes ABCs. Pass them to `register_plugin`; the kit validates the common +identity seam and forwards each instance to the specialized context registry: + +```python +return register_plugin( + ctx, + (), + memory_providers=(MyMemoryProvider(),), + image_gen_providers=(MyImageGenProvider(),), + video_gen_providers=(MyVideoGenProvider(),), +) +``` + +Memory providers must run through Hermes' memory-provider discovery context. +Image and video providers run through the general `PluginContext`. The kit does +not decorate provider methods or replace the `MemoryProvider`, +`ImageGenProvider`, or `VideoGenProvider` contracts. + The ordinary path remains Hermes' host-managed `send_message`. Because that host contract does not currently expose Telegram's `has_spoiler`, only `spoiler=True` uses the kit's narrow Telegram extension. The extension accepts diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index af640e4..825c5f7 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -65,7 +65,7 @@ def register(ctx): from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Any, Callable, Iterable, Iterator, Protocol +from typing import Any, Callable, Iterable, Iterator, Mapping, Protocol __all__ = [ "tool", @@ -73,6 +73,7 @@ def register(ctx): "middleware", "hook", "plugin_skill", + "get_subagent_lifecycle", "register_plugin", "log_registration_summary", "invoke_host_tool", @@ -331,6 +332,9 @@ class RegistrationSummary: skipped_optional_skills: tuple[str, ...] = () commands: tuple[str, ...] = () middlewares: tuple[str, ...] = () + memory_providers: tuple[str, ...] = () + image_gen_providers: tuple[str, ...] = () + video_gen_providers: tuple[str, ...] = () cli_commands: tuple[str, ...] = () @@ -355,7 +359,9 @@ def log_registration_summary( logger.info( "hermes_plugin_kit: registered plugin lifecycle; plugin=%s; " "commands=%s; cli_commands=%s; tools=%s; middlewares=%s; hooks=%s; " - "skills=%s; skipped_optional_skills=%s", + "skills=%s; skipped_optional_skills=%s; memory_providers=%s; " + "image_gen_providers=%s; " + "video_gen_providers=%s", clean_plugin_name, ",".join(summary.commands) or "", ",".join(summary.cli_commands) or "", @@ -364,6 +370,9 @@ def log_registration_summary( ",".join(summary.hooks) or "", ",".join(summary.skills) or "", ",".join(summary.skipped_optional_skills) or "", + ",".join(summary.memory_providers) or "", + ",".join(summary.image_gen_providers) or "", + ",".join(summary.video_gen_providers) or "", ) @@ -1043,6 +1052,125 @@ def wrapper(**kwargs: Any) -> Any: return decorate +def _require_string_list(value: Any, field: str) -> None: + if not isinstance(value, list) or any( + not isinstance(item, str) or not item.strip() for item in value + ): + raise ValueError(f"SKILL.md {field} must be a list of non-empty strings") + + +def _validate_hermes_skill_metadata(frontmatter: Mapping[str, Any]) -> None: + platforms = frontmatter.get("platforms") + if platforms is not None: + _require_string_list(platforms, "platforms") + invalid = sorted(set(platforms) - {"macos", "linux", "windows"}) + if invalid: + raise ValueError( + "SKILL.md platforms contains unsupported values: " + + ", ".join(invalid) + ) + + metadata = frontmatter.get("metadata", {}) + if not isinstance(metadata, Mapping): + raise ValueError("SKILL.md metadata must be a mapping") + hermes = metadata.get("hermes", {}) + if not isinstance(hermes, Mapping): + raise ValueError("SKILL.md metadata.hermes must be a mapping") + for field in ( + "tags", + "related_skills", + "requires_toolsets", + "requires_tools", + "fallback_for_toolsets", + "fallback_for_tools", + ): + if field in hermes: + _require_string_list(hermes[field], f"metadata.hermes.{field}") + + config = hermes.get("config") + if config is not None: + if not isinstance(config, list): + raise ValueError("SKILL.md metadata.hermes.config must be a list") + for item in config: + if not isinstance(item, Mapping): + raise ValueError("SKILL.md metadata.hermes.config entries must be mappings") + for required in ("key", "description"): + if not isinstance(item.get(required), str) or not item[required].strip(): + raise ValueError( + f"SKILL.md metadata.hermes.config entries require {required}" + ) + + blueprint = hermes.get("blueprint") + if blueprint is not None and not isinstance(blueprint, Mapping): + raise ValueError("SKILL.md metadata.hermes.blueprint must be a mapping") + + for field, required_key in ( + ("required_environment_variables", "name"), + ("required_credential_files", "path"), + ): + entries = frontmatter.get(field) + if entries is None: + continue + if not isinstance(entries, list): + raise ValueError(f"SKILL.md {field} must be a list") + for item in entries: + if not isinstance(item, Mapping): + raise ValueError(f"SKILL.md {field} entries must be mappings") + value = item.get(required_key) + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"SKILL.md {field} entries require {required_key}" + ) + + +def _read_skill_frontmatter(skill_path: Path) -> tuple[dict[str, Any], str]: + try: + content = skill_path.read_text(encoding="utf-8") + except FileNotFoundError: + raise + except (OSError, UnicodeError) as exc: + raise ValueError(f"unable to read SKILL.md: {exc}") from exc + normalized = content.lstrip("\ufeff") + if not normalized.startswith("---\n"): + raise ValueError("SKILL.md must start with YAML frontmatter") + end = re.search(r"\n---\s*\n", normalized[4:]) + if end is None: + raise ValueError("SKILL.md frontmatter is not closed") + yaml_text = normalized[4 : end.start() + 4] + try: + import yaml + + loader = getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader + parsed = yaml.load(yaml_text, Loader=loader) + except Exception as exc: + raise ValueError(f"SKILL.md frontmatter is invalid YAML: {exc}") from exc + if not isinstance(parsed, dict): + raise ValueError("SKILL.md frontmatter must be a mapping") + body = normalized[end.end() + 4 :] + if not body.strip(): + raise ValueError("SKILL.md must contain instructions after frontmatter") + return parsed, body + + +def _validate_plugin_skill_file( + name: str, skill_path: Path, description: str +) -> None: + frontmatter, _ = _read_skill_frontmatter(skill_path) + declared_name = frontmatter.get("name") + declared_description = frontmatter.get("description") + if declared_name != name: + raise ValueError( + f"SKILL.md name {declared_name!r} does not match declaration {name!r}" + ) + if not isinstance(declared_description, str) or not declared_description.strip(): + raise ValueError("SKILL.md description must be a non-empty string") + if len(declared_description) > 1024: + raise ValueError("SKILL.md description must not exceed 1024 characters") + if declared_description.strip() != description.strip(): + raise ValueError("SKILL.md description does not match plugin_skill declaration") + _validate_hermes_skill_metadata(frontmatter) + + def plugin_skill( name: str, path: str | Path, @@ -1057,9 +1185,28 @@ def plugin_skill( raise ValueError("skill path must point to SKILL.md") if not isinstance(description, str) or not description.strip(): raise ValueError("skill description is required") + try: + _validate_plugin_skill_file(name, skill_path, description) + except FileNotFoundError: + if optional: + return PluginSkill(name, skill_path, description.strip(), True) + raise FileNotFoundError(f"SKILL.md not found at {skill_path}") return PluginSkill(name, skill_path, description.strip(), bool(optional)) +def get_subagent_lifecycle(ctx: Any) -> Any: + """Return Hermes' public subagent lifecycle service after contract checking.""" + service = getattr(ctx, "subagent_lifecycle", None) + required = ("launch", "status", "wait", "cancel", "result", "reconnect") + missing = [name for name in required if not callable(getattr(service, name, None))] + if missing: + raise RuntimeError( + "hermes-agent subagent lifecycle API is unavailable or incompatible; " + "missing: " + ", ".join(missing) + ) + return service + + def _load_host_tool(name: str) -> Callable: target = _HOST_TOOL_IMPORTS.get(name) if target is None: @@ -1750,21 +1897,47 @@ def _register_tool(ctx: Any, handler: Callable, spec: dict[str, Any]) -> None: ) +def _register_generation_providers( + ctx: Any, + providers: Iterable[Any], + *, + kind: str, + registrar_name: str, +) -> list[str]: + registrar = getattr(ctx, registrar_name, None) + if not callable(registrar): + raise RuntimeError(f"this Hermes plugin context does not support {kind} providers") + registered: list[str] = [] + for provider in providers: + name = getattr(provider, "name", None) + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"{kind} providers require a non-empty name") + if not callable(getattr(provider, "generate", None)): + raise ValueError(f"{kind} provider {name!r} requires generate()") + registrar(provider) + registered.append(name) + return registered + + def register_plugin( ctx: Any, module: Any | Iterable[Callable], skills: tuple[PluginSkill, ...] | list[PluginSkill] = (), *, + memory_providers: tuple[Any, ...] | list[Any] = (), + image_gen_providers: tuple[Any, ...] | list[Any] = (), + video_gen_providers: tuple[Any, ...] | list[Any] = (), plugin_name: str | None = None, logger: logging.Logger | None = None, ) -> RegistrationSummary: - """Register decorated slash/CLI commands, tools, middleware, hooks, and skills. + """Register decorated surfaces plus specialized Hermes providers. Unlike the backward-compatible :func:`register_all`, this lifecycle-level entrypoint rejects distinct declarations that share a public name. Missing optional skills are warned and skipped; missing required skills fail fast. Pass a module (or loaded module name) to discover all declarations, or an iterable of decorated callables to register only a runtime-active subset. + Provider instances retain their Hermes ABC contracts and are not decorated. """ if isinstance(module, str): module = sys.modules[module] @@ -1880,11 +2053,13 @@ def register_plugin( skipped_skills: list[str] = [] for name in sorted(declared_skills): skill = declared_skills[name] - if skill.path.is_file(): + try: + _validate_plugin_skill_file(skill.name, skill.path, skill.description) available_skills.append(skill) continue - if not skill.optional: - raise FileNotFoundError(f"SKILL.md not found at {skill.path}") + except FileNotFoundError: + if not skill.optional: + raise FileNotFoundError(f"SKILL.md not found at {skill.path}") log.warning( "hermes_plugin_kit: optional skill missing; name=%s; path=%s", skill.name, @@ -1943,6 +2118,33 @@ def register_plugin( ) registered_skills.append(skill.name) + registered_memory_providers: list[str] = [] + register_memory_provider = getattr(ctx, "register_memory_provider", None) + if memory_providers and not callable(register_memory_provider): + raise RuntimeError( + "this Hermes plugin context does not support memory providers; " + "use the memory-provider discovery path" + ) + for provider in memory_providers: + name = getattr(provider, "name", None) + if not isinstance(name, str) or not name.strip(): + raise ValueError("memory providers require a non-empty name") + register_memory_provider(provider) + registered_memory_providers.append(name) + + registered_image_gen_providers = _register_generation_providers( + ctx, + image_gen_providers, + kind="image generation", + registrar_name="register_image_gen_provider", + ) if image_gen_providers else [] + registered_video_gen_providers = _register_generation_providers( + ctx, + video_gen_providers, + kind="video generation", + registrar_name="register_video_gen_provider", + ) if video_gen_providers else [] + summary = RegistrationSummary( commands=tuple(registered_slash_commands), cli_commands=tuple(registered_cli_commands), @@ -1951,6 +2153,9 @@ def register_plugin( hooks=tuple(registered_hooks), skills=tuple(registered_skills), skipped_optional_skills=tuple(skipped_skills), + memory_providers=tuple(registered_memory_providers), + image_gen_providers=tuple(registered_image_gen_providers), + video_gen_providers=tuple(registered_video_gen_providers), ) log_registration_summary(log, resolved_plugin_name, summary) return summary diff --git a/pyproject.toml b/pyproject.toml index 676b073..9a444e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,15 +15,15 @@ requires-python = ">=3.11" license = { text = "MIT" } authors = [{ name = "Offending Commit", email = "offendingcommit@gmail.com" }] keywords = ["hermes", "hermes-agent", "plugin", "llm-tools"] -dependencies = [] +dependencies = ["pyyaml>=6"] [project.urls] Repository = "https://github.com/offendingcommit/hermes-plugin-kit" -# Runtime stays dependency-free. The hermes contract tests import current -# upstream source, whose plugin and gateway seams transitively need these. +# The runtime uses PyYAML for strict SKILL.md validation. Contract tests import +# current upstream Hermes source, whose plugin and gateway seams need these. [dependency-groups] -dev = ["httpx[socks]==0.28.1", "pyyaml", "requests==2.33.0"] +dev = ["httpx[socks]==0.28.1", "requests==2.33.0"] [tool.setuptools] packages = ["hermes_plugin_kit"] diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index c3bc97c..d4ff9a8 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -75,6 +75,18 @@ def _try(): raise ImportError( "hermes-agent PluginContext.register_middleware is unavailable" ) + if not hasattr(PluginContext, "subagent_lifecycle"): + raise ImportError( + "hermes-agent PluginContext.subagent_lifecycle is unavailable" + ) + if not hasattr(PluginContext, "register_image_gen_provider"): + raise ImportError( + "hermes-agent PluginContext.register_image_gen_provider is unavailable" + ) + if not hasattr(PluginContext, "register_video_gen_provider"): + raise ImportError( + "hermes-agent PluginContext.register_video_gen_provider is unavailable" + ) return types.SimpleNamespace( apply_llm_request_middleware=apply_llm_request_middleware, @@ -384,7 +396,11 @@ def contract_hook(**kwargs): with TemporaryDirectory() as tmp: path = Path(tmp) / "SKILL.md" - path.write_text("# Contract skill\n") + path.write_text( + "---\nname: probe\ndescription: Contract probe\n" + "metadata:\n hermes:\n tags: [Contract]\n" + "---\n# Contract skill\n" + ) with self.assertLogs("contract_lifecycle_plugin", level="INFO") as cap: summary = hpk.register_plugin( ctx, @@ -397,7 +413,9 @@ def contract_hook(**kwargs): "plugin=contract-plugin; commands=; " "cli_commands=; tools=; middlewares=; " "hooks=pre_llm_call; skills=probe; " - "skipped_optional_skills=", + "skipped_optional_skills=; " + "memory_providers=; " + "image_gen_providers=; video_gen_providers=", cap.records[0].getMessage(), ) self.assertEqual( @@ -605,6 +623,18 @@ def test_lifecycle_calls_bind_to_real_plugincontext_signatures(self) -> None: description="Probe", ) + image_sig = inspect.signature(_REAL.PluginContext.register_image_gen_provider) + image_sig.bind(None, object()) + video_sig = inspect.signature(_REAL.PluginContext.register_video_gen_provider) + video_sig.bind(None, object()) + + manager = _REAL.PluginManager() + manifest = _REAL.PluginManifest(name="contract-plugin") + ctx = _REAL.PluginContext(manifest, manager) + service = hpk.get_subagent_lifecycle(ctx) + for method in ("launch", "status", "wait", "cancel", "result", "reconnect"): + self.assertTrue(callable(getattr(service, method))) + def test_host_tool_invocation_reaches_real_telegram_media_contract(self) -> None: """Exercise Hermes media parsing and Telegram formatting without network I/O.""" import asyncio diff --git a/tests/test_kit.py b/tests/test_kit.py index bcda5ee..287a4ef 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -31,6 +31,17 @@ def __init__(self) -> None: self.middlewares: list[tuple[str, object]] = [] self.hooks: list[tuple[str, object]] = [] self.skills: list[dict] = [] + self.image_gen_providers: list[object] = [] + self.video_gen_providers: list[object] = [] + self.memory_providers: list[object] = [] + self.subagent_lifecycle = types.SimpleNamespace( + launch=lambda request: request, + status=lambda handle: handle, + wait=lambda handle, **kwargs: handle, + cancel=lambda handle, **kwargs: handle, + result=lambda handle: handle, + reconnect=lambda handle: handle, + ) def register_command(self, **kwargs) -> None: self.commands.append(kwargs) @@ -47,6 +58,15 @@ def register_hook(self, hook_name, callback) -> None: def register_skill(self, **kwargs) -> None: self.skills.append(kwargs) + def register_image_gen_provider(self, provider) -> None: + self.image_gen_providers.append(provider) + + def register_video_gen_provider(self, provider) -> None: + self.video_gen_providers.append(provider) + + def register_memory_provider(self, provider) -> None: + self.memory_providers.append(provider) + class SessionDBHelperTests(unittest.TestCase): def test_injected_db_is_delegated_to_and_remains_open(self) -> None: @@ -1351,7 +1371,10 @@ def request_middleware(**kwargs): with tempfile.TemporaryDirectory() as tmp: skill_path = Path(tmp) / "SKILL.md" - skill_path.write_text("# Skill\n") + skill_path.write_text( + "---\nname: temporal-awareness\n" + "description: Use local timing context.\n---\n# Skill\n" + ) skill = hpk.plugin_skill( "temporal-awareness", skill_path, "Use local timing context." ) @@ -1409,6 +1432,84 @@ def request_middleware(**kwargs): self.assertIn("hooks=pre_llm_call", "\n".join(cap.output)) self.assertIn("skills=temporal-awareness", "\n".join(cap.output)) + def test_plugin_skill_validates_frontmatter_and_matches_declaration(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + skill_path = Path(tmp) / "SKILL.md" + skill_path.write_text( + "---\n" + "name: temporal-awareness\n" + "description: Use local timing context.\n" + "platforms: [macos, linux]\n" + "metadata:\n" + " hermes:\n" + " tags: [Time, Context]\n" + " requires_toolsets: [terminal]\n" + "required_environment_variables:\n" + " - name: TIME_API_KEY\n" + " prompt: Time API key\n" + "---\n" + "# Temporal awareness\n" + ) + + skill = hpk.plugin_skill( + "temporal-awareness", skill_path, "Use local timing context." + ) + + self.assertEqual(skill.name, "temporal-awareness") + + def test_plugin_skill_rejects_invalid_or_drifting_frontmatter(self) -> None: + invalid_documents = { + "missing": "# Skill\n", + "name": "---\nname: other\ndescription: Description\n---\n# Skill\n", + "description": "---\nname: sample\ndescription: Other\n---\n# Skill\n", + "platforms": ( + "---\nname: sample\ndescription: Description\n" + "platforms: [plan9]\n---\n# Skill\n" + ), + "hermes": ( + "---\nname: sample\ndescription: Description\n" + "metadata:\n hermes:\n requires_tools: terminal\n" + "---\n# Skill\n" + ), + "empty-body": "---\nname: sample\ndescription: Description\n---\n", + } + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "SKILL.md" + for label, document in invalid_documents.items(): + with self.subTest(label=label): + path.write_text(document) + with self.assertRaises(ValueError): + hpk.plugin_skill("sample", path, "Description") + + def test_registers_specialized_providers_without_decorating_them(self) -> None: + image_provider = types.SimpleNamespace(name="image", generate=lambda prompt: prompt) + video_provider = types.SimpleNamespace(name="video", generate=lambda prompt: prompt) + memory_provider = types.SimpleNamespace(name="memory") + ctx = FakePluginCtx() + + summary = hpk.register_plugin( + ctx, + self._module(), + memory_providers=(memory_provider,), + image_gen_providers=(image_provider,), + video_gen_providers=(video_provider,), + ) + + self.assertEqual(ctx.memory_providers, [memory_provider]) + self.assertEqual(ctx.image_gen_providers, [image_provider]) + self.assertEqual(ctx.video_gen_providers, [video_provider]) + self.assertEqual(summary.memory_providers, ("memory",)) + self.assertEqual(summary.image_gen_providers, ("image",)) + self.assertEqual(summary.video_gen_providers, ("video",)) + + def test_get_subagent_lifecycle_requires_the_public_service_contract(self) -> None: + ctx = FakePluginCtx() + self.assertIs(hpk.get_subagent_lifecycle(ctx), ctx.subagent_lifecycle) + + ctx.subagent_lifecycle = types.SimpleNamespace(launch=lambda request: request) + with self.assertRaises(RuntimeError): + hpk.get_subagent_lifecycle(ctx) + def test_logs_one_stable_registration_receipt_with_actual_names(self) -> None: logger = logging.getLogger("registration-receipt-test") summary = hpk.RegistrationSummary( @@ -1431,7 +1532,9 @@ def test_logs_one_stable_registration_receipt_with_actual_names(self) -> None: "cli_commands=; tools=sample_read_thread; " "middlewares=tool_request; " "hooks=pre_llm_call; skills=temporal-awareness; " - "skipped_optional_skills=missing-optional", + "skipped_optional_skills=missing-optional; " + "memory_providers=; " + "image_gen_providers=; video_gen_providers=", ) def test_register_plugin_uses_public_registration_summary_logger(self) -> None: @@ -1505,17 +1608,8 @@ def test_missing_required_skill_raises(self) -> None: def callback(**kwargs): return kwargs - ctx = FakePluginCtx() - skill = hpk.plugin_skill("required", "/missing/SKILL.md", "Required") with self.assertRaises(FileNotFoundError): - hpk.register_plugin( - ctx, - self._module(callback=callback, sample_read=sample_read), - skills=(skill,), - ) - self.assertEqual(ctx.tools, []) - self.assertEqual(ctx.hooks, []) - self.assertEqual(ctx.skills, []) + hpk.plugin_skill("required", "/missing/SKILL.md", "Required") def test_validates_skill_name_path_and_description(self) -> None: for args in [ diff --git a/uv.lock b/uv.lock index bd5d6d1..2322f6a 100644 --- a/uv.lock +++ b/uv.lock @@ -111,20 +111,22 @@ wheels = [ name = "hermes-plugin-kit" version = "0.7.0" source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] [package.dev-dependencies] dev = [ { name = "httpx", extra = ["socks"] }, - { name = "pyyaml" }, { name = "requests" }, ] [package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=6" }] [package.metadata.requires-dev] dev = [ { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, - { name = "pyyaml" }, { name = "requests", specifier = "==2.33.0" }, ]