diff --git a/AGENTS.md b/AGENTS.md index 5c669f9..e6b7268 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,9 +7,11 @@ plugin. ## Working Rules -- Keep `@tool` and `register_all` backward compatible. Use `@command`, - `@middleware`, `@hook`, `plugin_skill`, and `register_plugin` for full plugin - lifecycle registration. +- Keep `@tool`, slash-default `@command`, and `register_all` backward + compatible. Use `@command(type="cli")` for terminal subcommands and + `@command(type="slash")` for explicit in-session commands; use + `@middleware`, `@hook`, `plugin_skill`, and `register_plugin` for full + plugin lifecycle registration. - Use `load_plugin_config` for effective `plugins.` runtime settings; current Hermes `PluginManifest` objects do not expose profile config. Use `configure_stderr_logging` for operator-gated registration receipts instead diff --git a/README.md b/README.md index 65c6b5a..c8b7ee6 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,12 @@ ![python](https://img.shields.io/badge/python-3.11%2B-blue) `hermes-plugin-kit` is a tiny, dependency-free helper for authoring plugins for -[hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate a slash -command with `@command`, a tool with `@tool`, or a lifecycle callback with -`@middleware` or `@hook`, then use `register_plugin` to register commands, -tools, middleware, hooks, and plugin-owned skills together. Existing tool-only -plugins can keep using `register_all`; the LLM-facing schema, +[hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate an +in-session slash command or terminal CLI subcommand with `@command`, a tool +with `@tool`, or a lifecycle callback with `@middleware` or `@hook`, then use +`register_plugin` to register commands, tools, middleware, hooks, and +plugin-owned skills together. Existing tool-only plugins can keep using +`register_all`; the LLM-facing schema, argument validation, structured logging, and the JSON result envelope are all generated for you — correctly, every time. @@ -140,6 +141,7 @@ Use the lifecycle entrypoint when a plugin provides more than tools: import time from pathlib import Path from hermes_plugin_kit import ( + CommandType, MiddlewareKind, command, hook, @@ -153,6 +155,19 @@ def valdris_status(raw_args): """Show the current Valdris plugin status.""" return build_status(raw_args) +def configure_valdris_cli(parser): + parser.add_argument("--scope", default="all") + +@command( + "valdris", + type=CommandType.CLI, + help="Manage Valdris", + setup_fn=configure_valdris_cli, +) +def valdris_cli(args): + """Manage Valdris from the terminal.""" + return run_valdris_cli(scope=args.scope) + @middleware(MiddlewareKind.TOOL_REQUEST) def normalize_tool_request(**kwargs): args = {**kwargs["args"]} @@ -187,12 +202,21 @@ def register(ctx): return register_plugin(ctx, __name__, skills=SKILLS) ``` -`@command` requires a bare lowercase kebab-case name without the leading slash. -Its handler receives the trailing command text unchanged and may return -`str | None` synchronously or asynchronously. The optional `args_hint` is -forwarded to Hermes for native command pickers. Command logs include only the -command name, elapsed time, result type, and argument character count, never -the raw arguments. +`@command` requires a bare lowercase kebab-case name. Slash commands are the +backward-compatible default: the handler receives trailing command text +unchanged and may return `str | None` synchronously or asynchronously. The +optional `args_hint` is forwarded to Hermes for native command pickers. + +Use `type=CommandType.CLI` (or `type="cli"`) for a terminal command such as +`hermes valdris`. Its synchronous handler receives the parsed +`argparse.Namespace`. `setup_fn` configures that command's argparse subparser; +omit it for a command with no command-specific arguments. `help` defaults to +the first line of the resolved description. CLI commands cannot use +`args_hint`, and slash commands cannot use `help` or `setup_fn`. + +Command logs include only the command name, elapsed time, result type, and, +for slash commands, the argument character count. Raw slash text and parsed +CLI argument values are never logged. `@middleware` changes runtime behavior rather than merely observing it. Request middleware rewrites the effective payload before Hermes continues; execution diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index 4d49b81..b66c087 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -49,6 +49,7 @@ def register(ctx): from __future__ import annotations +import argparse import copy import functools import importlib @@ -82,6 +83,7 @@ def register(ctx): "MediaPayload", "ResolvedDeliveryTarget", "MediaDeliveryResult", + "CommandType", "MiddlewareKind", "PluginSkill", "RegistrationSummary", @@ -144,6 +146,14 @@ class RegistrationSummary: skipped_optional_skills: tuple[str, ...] = () commands: tuple[str, ...] = () middlewares: tuple[str, ...] = () + cli_commands: tuple[str, ...] = () + + +class CommandType(str, Enum): + """Command surfaces currently supported by hermes-agent.""" + + SLASH = "slash" + CLI = "cli" def log_registration_summary( @@ -159,10 +169,11 @@ def log_registration_summary( raise TypeError("summary must be a RegistrationSummary") logger.info( "hermes_plugin_kit: registered plugin lifecycle; plugin=%s; " - "commands=%s; tools=%s; middlewares=%s; hooks=%s; skills=%s; " - "skipped_optional_skills=%s", + "commands=%s; cli_commands=%s; tools=%s; middlewares=%s; hooks=%s; " + "skills=%s; skipped_optional_skills=%s", clean_plugin_name, ",".join(summary.commands) or "", + ",".join(summary.cli_commands) or "", ",".join(summary.tools) or "", ",".join(summary.middlewares) or "", ",".join(summary.hooks) or "", @@ -524,17 +535,38 @@ def _safe_context(kwargs: dict[str, Any]) -> dict[str, Any]: # Decorators # --------------------------------------------------------------------------- +def _noop_cli_setup(_parser: argparse.ArgumentParser) -> None: + """Configure a CLI command that accepts no command-specific arguments.""" + + def command( name: str, description: str | None = None, args_hint: str = "", + *, + type: CommandType | str = CommandType.SLASH, + help: str | None = None, + setup_fn: Callable[[argparse.ArgumentParser], None] | None = None, ) -> Callable: - """Mark and instrument a Hermes in-session slash command. + """Mark and instrument a Hermes slash or terminal CLI command. + + Slash commands are the backward-compatible default. Their handler receives + the original ``raw_args`` string and may be synchronous or asynchronous. - ``name`` is the bare command name without a leading slash. The wrapped - handler receives the original ``raw_args`` string and returns ``str | None``. - Synchronous and asynchronous handlers preserve their native callable shape. + CLI commands use ``type="cli"``. Their synchronous handler receives an + ``argparse.Namespace``. ``setup_fn`` may configure the argparse subparser; + when omitted, the command accepts no command-specific arguments. ``help`` + defaults to the first line of the resolved description. """ + if isinstance(type, CommandType): + command_type = type + elif isinstance(type, str): + try: + command_type = CommandType(type.strip().lower()) + except ValueError: + raise ValueError("command type must be 'slash' or 'cli'") from None + else: + raise TypeError("command type must be a CommandType or string") if not isinstance(name, str) or not _COMMAND_NAME_RE.fullmatch(name): raise ValueError( "command name must be a bare lowercase kebab-case name matching " @@ -544,7 +576,24 @@ def command( raise TypeError("command description must be a string or None") if not isinstance(args_hint, str): raise TypeError("command args_hint must be a string") + if help is not None and not isinstance(help, str): + raise TypeError("command help must be a string or None") + if setup_fn is not None and not callable(setup_fn): + raise TypeError("command setup_fn must be callable or None") clean_args_hint = args_hint.strip() + clean_help = (help or "").strip() + if command_type is CommandType.CLI and clean_args_hint: + raise ValueError("command args_hint is only valid for slash commands") + if command_type is CommandType.SLASH and help is not None: + raise ValueError("command help is only valid for CLI commands") + if command_type is CommandType.SLASH and setup_fn is not None: + raise ValueError("command setup_fn is only valid for CLI commands") + if ( + command_type is CommandType.CLI + and setup_fn is not None + and inspect.iscoroutinefunction(setup_fn) + ): + raise TypeError("CLI command setup_fn must be synchronous") def decorate(fn: Callable) -> Callable: explicit_description = (description or "").strip() @@ -554,9 +603,11 @@ def decorate(fn: Callable) -> Callable: f"@command {name!r}: a description is required " "(docstring or description=)." ) + if command_type is CommandType.CLI and inspect.iscoroutinefunction(fn): + raise TypeError("CLI command handler must be synchronous") log = logging.getLogger(fn.__module__ or "hermes_plugin_kit") - def log_invocation(raw_args: str) -> float: + def log_slash_invocation(raw_args: str) -> float: started = time.perf_counter() try: args_chars = len(raw_args) @@ -565,12 +616,17 @@ def log_invocation(raw_args: str) -> float: log.debug("%s: invoked; args_chars=%d", name, args_chars) return started + def log_cli_invocation() -> float: + started = time.perf_counter() + log.info("%s: invoked; type=cli", name) + return started + def log_failure(started: float, exc: Exception) -> None: log.warning( "%s: handler raised; elapsed_ms=%.2f; error_type=%s", name, (time.perf_counter() - started) * 1000, - type(exc).__name__, + exc.__class__.__name__, ) def log_success(started: float, result: Any) -> None: @@ -578,14 +634,28 @@ def log_success(started: float, result: Any) -> None: "%s: ok; elapsed_ms=%.2f; result=%s", name, (time.perf_counter() - started) * 1000, - type(result).__name__, + result.__class__.__name__, ) - if inspect.iscoroutinefunction(fn): + if command_type is CommandType.CLI: + + @functools.wraps(fn) + def cli_wrapper(args: argparse.Namespace) -> Any: + started = log_cli_invocation() + try: + result = fn(args) + except Exception as exc: + log_failure(started, exc) + raise + log_success(started, result) + return result + + wrapper = cli_wrapper + elif inspect.iscoroutinefunction(fn): @functools.wraps(fn) async def async_wrapper(raw_args: str) -> str | None: - started = log_invocation(raw_args) + started = log_slash_invocation(raw_args) try: result = await fn(raw_args) except Exception as exc: @@ -599,7 +669,7 @@ async def async_wrapper(raw_args: str) -> str | None: @functools.wraps(fn) def sync_wrapper(raw_args: str) -> str | None: - started = log_invocation(raw_args) + started = log_slash_invocation(raw_args) try: result = fn(raw_args) except Exception as exc: @@ -610,13 +680,26 @@ def sync_wrapper(raw_args: str) -> str | None: wrapper = sync_wrapper + command_help: str | None = None + command_setup_fn: Callable[[argparse.ArgumentParser], None] | None = None + if command_type is CommandType.CLI: + command_help = clean_help or next( + line.strip() + for line in doc.splitlines() + if line.strip() + ) + command_setup_fn = setup_fn or _noop_cli_setup + setattr( wrapper, _COMMAND_SPEC_ATTR, { "name": name, + "type": command_type.value, "description": doc, "args_hint": clean_args_hint, + "help": command_help, + "setup_fn": command_setup_fn, }, ) return wrapper @@ -1423,7 +1506,7 @@ def register_plugin( module: Any, skills: tuple[PluginSkill, ...] | list[PluginSkill] = (), ) -> RegistrationSummary: - """Register decorated commands, tools, middleware, hooks, and skills. + """Register decorated slash/CLI commands, tools, middleware, hooks, and skills. Unlike the backward-compatible :func:`register_all`, this lifecycle-level entrypoint rejects distinct declarations that share a public name. Missing @@ -1433,17 +1516,33 @@ def register_plugin( module = sys.modules[module] log = logging.getLogger(getattr(module, "__name__", "hermes_plugin_kit")) - commands: dict[str, Callable] = {} + slash_commands: dict[str, Callable] = {} + cli_commands: dict[str, Callable] = {} tools: dict[str, Callable] = {} middlewares: dict[str, Callable] = {} hooks: dict[str, Callable] = {} for _, obj in inspect.getmembers(module): command_spec = getattr(obj, _COMMAND_SPEC_ATTR, None) if command_spec: - existing = commands.get(command_spec["name"]) + raw_command_type = command_spec.get("type", CommandType.SLASH.value) + try: + command_type = CommandType(raw_command_type) + except (TypeError, ValueError): + raise ValueError( + f"unsupported command type: {raw_command_type!r}" + ) from None + command_registry = ( + cli_commands + if command_type is CommandType.CLI + else slash_commands + ) + existing = command_registry.get(command_spec["name"]) if existing is not None and existing is not obj: - raise ValueError(f"duplicate command name: {command_spec['name']}") - commands[command_spec["name"]] = obj + label = "CLI command" if command_type is CommandType.CLI else "command" + raise ValueError( + f"duplicate {label} name: {command_spec['name']}" + ) + command_registry[command_spec["name"]] = obj tool_spec = getattr(obj, _SPEC_ATTR, None) if tool_spec: @@ -1492,9 +1591,9 @@ def register_plugin( ) skipped_skills.append(name) - registered_commands: list[str] = [] - for name in sorted(commands): - obj = commands[name] + registered_slash_commands: list[str] = [] + for name in sorted(slash_commands): + obj = slash_commands[name] spec = getattr(obj, _COMMAND_SPEC_ATTR) ctx.register_command( name=spec["name"], @@ -1502,7 +1601,20 @@ def register_plugin( description=spec["description"], args_hint=spec["args_hint"], ) - registered_commands.append(name) + registered_slash_commands.append(name) + + registered_cli_commands: list[str] = [] + for name in sorted(cli_commands): + obj = cli_commands[name] + spec = getattr(obj, _COMMAND_SPEC_ATTR) + ctx.register_cli_command( + name=spec["name"], + help=spec["help"], + setup_fn=spec["setup_fn"], + handler_fn=obj, + description=spec["description"], + ) + registered_cli_commands.append(name) registered_tools: list[str] = [] for name in sorted(tools): @@ -1531,7 +1643,8 @@ def register_plugin( registered_skills.append(skill.name) summary = RegistrationSummary( - commands=tuple(registered_commands), + commands=tuple(registered_slash_commands), + cli_commands=tuple(registered_cli_commands), tools=tuple(registered_tools), middlewares=tuple(registered_middlewares), hooks=tuple(registered_hooks), diff --git a/pyproject.toml b/pyproject.toml index 738fbc6..6fb58b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-plugin-kit" -version = "0.6.0" +version = "0.7.0" description = "Convention-correct middleware and lifecycle registration for hermes-agent plugins." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index 9d89c90..a456f99 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -17,6 +17,7 @@ from __future__ import annotations +import argparse import inspect import json import os @@ -223,8 +224,9 @@ def contract_hook(**kwargs): self.assertEqual(summary.hooks, ("pre_llm_call",)) self.assertEqual(len(cap.records), 1) self.assertIn( - "plugin=contract-plugin; commands=; tools=; " - "middlewares=; hooks=pre_llm_call; skills=probe; " + "plugin=contract-plugin; commands=; " + "cli_commands=; tools=; middlewares=; " + "hooks=pre_llm_call; skills=probe; " "skipped_optional_skills=", cap.records[0].getMessage(), ) @@ -253,6 +255,35 @@ def test_command_registers_and_dispatches_through_real_plugin_context(self) -> N "command:exact raw args", ) + def test_cli_command_registers_and_dispatches_through_real_plugin_context(self) -> None: + def setup_parser(parser): + parser.add_argument("--scope", required=True) + + @hpk.command( + "hpk-contract", + type=hpk.CommandType.CLI, + help="Run the plugin-kit contract probe", + description="Exercise terminal CLI command registration.", + setup_fn=setup_parser, + ) + def cli_handler(args): + return f"cli:{args.scope}" + + manager = _REAL.PluginManager() + manifest = _REAL.PluginManifest(name="contract-plugin") + ctx = _REAL.PluginContext(manifest, manager) + module = types.ModuleType("contract_cli_command_plugin") + module.cli_handler = cli_handler + + summary = hpk.register_plugin(ctx, module) + + self.assertEqual(summary.cli_commands, ("hpk-contract",)) + entry = manager._cli_commands["hpk-contract"] + parser = argparse.ArgumentParser() + entry["setup_fn"](parser) + args = parser.parse_args(["--scope", "exact"]) + self.assertEqual(entry["handler_fn"](args), "cli:exact") + def test_middleware_registers_and_runs_all_real_hermes_contracts(self) -> None: request_calls: list[tuple[str, dict]] = [] execution_calls: list[tuple[str, dict]] = [] @@ -380,6 +411,16 @@ def test_lifecycle_calls_bind_to_real_plugincontext_signatures(self) -> None: args_hint="", ) + cli_command_sig = inspect.signature(_REAL.PluginContext.register_cli_command) + cli_command_sig.bind( + None, + name="probe", + help="Run the probe", + setup_fn=lambda parser: None, + handler_fn=lambda args: args, + description="Probe terminal command.", + ) + hook_sig = inspect.signature(_REAL.PluginContext.register_hook) hook_sig.bind(None, "pre_llm_call", lambda **kwargs: None) diff --git a/tests/test_kit.py b/tests/test_kit.py index 6f20c0a..0bb567d 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import asyncio import inspect import json @@ -26,6 +27,7 @@ class FakePluginCtx(FakeCtx): def __init__(self) -> None: super().__init__() self.commands: list[dict] = [] + self.cli_commands: list[dict] = [] self.middlewares: list[tuple[str, object]] = [] self.hooks: list[tuple[str, object]] = [] self.skills: list[dict] = [] @@ -33,6 +35,9 @@ def __init__(self) -> None: def register_command(self, **kwargs) -> None: self.commands.append(kwargs) + def register_cli_command(self, **kwargs) -> None: + self.cli_commands.append(kwargs) + def register_middleware(self, kind, callback) -> None: self.middlewares.append((kind, callback)) @@ -478,6 +483,119 @@ def test_description_and_args_hint_must_be_strings(self) -> None: with self.assertRaisesRegex(TypeError, "args_hint"): hpk.command("valdris-help", description="Help.", args_hint=object()) + def test_cli_command_wraps_sync_handler_and_derives_help(self) -> None: + def setup_parser(parser): + parser.add_argument("--scope") + + @hpk.command( + "valdris", + type=hpk.CommandType.CLI, + setup_fn=setup_parser, + ) + def valdris(args): + """Manage Valdris state. + + Supports status and repair operations. + """ + return f"scope:{args.scope}" + + spec = getattr(valdris, "_hpk_command_spec") + self.assertEqual(spec["type"], "cli") + self.assertEqual(spec["help"], "Manage Valdris state.") + self.assertIs(spec["setup_fn"], setup_parser) + + args = types.SimpleNamespace(scope="private-value") + with self.assertLogs(level="INFO") as cap: + self.assertEqual(valdris(args), "scope:private-value") + joined = "\n".join(cap.output) + self.assertIn("valdris: invoked; type=cli", joined) + self.assertNotIn("private-value", joined) + + def test_cli_command_reraises_without_logging_namespace_or_error(self) -> None: + @hpk.command("valdris", type="cli", description="Manage Valdris.") + def valdris(args): + raise RuntimeError("private CLI failure") + + args = argparse.Namespace(token="private-value") + with self.assertLogs(level="WARNING") as cap: + with self.assertRaisesRegex(RuntimeError, "private CLI failure"): + valdris(args) + joined = "\n".join(cap.output) + self.assertIn("error_type=RuntimeError", joined) + self.assertNotIn("private CLI failure", joined) + self.assertNotIn("private-value", joined) + + def test_cli_command_supports_no_argument_setup(self) -> None: + @hpk.command( + "valdris-status", + type="cli", + help="Show Valdris status", + description="Show the current Valdris status.", + ) + def status(args): + return args + + spec = getattr(status, "_hpk_command_spec") + parser = Mock() + self.assertIsNone(spec["setup_fn"](parser)) + parser.assert_not_called() + self.assertEqual(spec["help"], "Show Valdris status") + + def test_cli_command_rejects_async_handler_and_slash_only_options(self) -> None: + with self.assertRaisesRegex(TypeError, "CLI command handler must be synchronous"): + + @hpk.command("valdris", type="cli", description="Manage Valdris.") + async def valdris(args): + return args + + async def setup_parser(parser): + return None + + with self.assertRaisesRegex(TypeError, "setup_fn must be synchronous"): + hpk.command( + "valdris", + type="cli", + description="Manage Valdris.", + setup_fn=setup_parser, + ) + + with self.assertRaisesRegex(ValueError, "args_hint is only valid"): + hpk.command( + "valdris", + type="cli", + description="Manage Valdris.", + args_hint="", + ) + + with self.assertRaisesRegex(ValueError, "help is only valid"): + hpk.command( + "valdris", + type="slash", + description="Manage Valdris.", + help="Manage Valdris", + ) + + with self.assertRaisesRegex(ValueError, "setup_fn is only valid"): + hpk.command( + "valdris", + type="slash", + description="Manage Valdris.", + setup_fn=lambda parser: None, + ) + + def test_command_rejects_invalid_type_and_cli_options(self) -> None: + with self.assertRaisesRegex(ValueError, "command type"): + hpk.command("valdris", type="terminal", description="Manage Valdris.") + with self.assertRaisesRegex(TypeError, "command help"): + hpk.command("valdris", type="cli", description="Manage Valdris.", help=1) + with self.assertRaisesRegex(TypeError, "setup_fn"): + hpk.command( + "valdris", + type="cli", + description="Manage Valdris.", + setup_fn="not-callable", + ) + class HostToolInvocationTests(unittest.TestCase): def _runtime_modules(self, *, block_message=None, result='{"success": true}'): @@ -993,6 +1111,19 @@ def _module(self, **attrs): setattr(module, name, value) return module + def test_registration_summary_preserves_positional_middleware_argument(self) -> None: + summary = hpk.RegistrationSummary( + (), + (), + (), + (), + (), + ("tool_request",), + ) + + self.assertEqual(summary.middlewares, ("tool_request",)) + self.assertEqual(summary.cli_commands, ()) + def test_registers_all_lifecycle_surfaces_with_summary(self) -> None: @hpk.command( "valdris-status", @@ -1002,6 +1133,19 @@ def test_registers_all_lifecycle_surfaces_with_summary(self) -> None: def command_handler(raw_args): return raw_args + def setup_cli(parser): + parser.add_argument("--scope") + + @hpk.command( + "valdris", + type="cli", + description="Manage Valdris from the terminal.", + help="Manage Valdris", + setup_fn=setup_cli, + ) + def cli_command_handler(args): + return args + @hpk.hook("pre_llm_call") def callback(**kwargs): return kwargs @@ -1019,6 +1163,7 @@ def request_middleware(**kwargs): ctx = FakePluginCtx() module = self._module( callback=callback, + cli_command_handler=cli_command_handler, command_handler=command_handler, request_middleware=request_middleware, sample_read=sample_read, @@ -1027,6 +1172,7 @@ def request_middleware(**kwargs): summary = hpk.register_plugin(ctx, module, skills=(skill,)) self.assertEqual(summary.commands, ("valdris-status",)) + self.assertEqual(summary.cli_commands, ("valdris",)) self.assertEqual(summary.tools, ("sample_read_thread",)) self.assertEqual(summary.middlewares, ("tool_request",)) self.assertEqual(summary.hooks, ("pre_llm_call",)) @@ -1043,6 +1189,18 @@ def request_middleware(**kwargs): } ], ) + self.assertEqual( + ctx.cli_commands, + [ + { + "name": "valdris", + "help": "Manage Valdris", + "setup_fn": setup_cli, + "handler_fn": cli_command_handler, + "description": "Manage Valdris from the terminal.", + } + ], + ) self.assertEqual( ctx.middlewares, [("tool_request", request_middleware)], @@ -1050,6 +1208,7 @@ def request_middleware(**kwargs): self.assertEqual(ctx.hooks, [("pre_llm_call", callback)]) self.assertEqual(ctx.skills[0]["name"], "temporal-awareness") self.assertIn("commands=valdris-status", "\n".join(cap.output)) + self.assertIn("cli_commands=valdris", "\n".join(cap.output)) self.assertIn("tools=sample_read_thread", "\n".join(cap.output)) self.assertIn("middlewares=tool_request", "\n".join(cap.output)) self.assertIn("hooks=pre_llm_call", "\n".join(cap.output)) @@ -1074,7 +1233,8 @@ def test_logs_one_stable_registration_receipt_with_actual_names(self) -> None: cap.records[0].getMessage(), "hermes_plugin_kit: registered plugin lifecycle; " "plugin=sample-plugin; commands=valdris-status; " - "tools=sample_read_thread; middlewares=tool_request; " + "cli_commands=; tools=sample_read_thread; " + "middlewares=tool_request; " "hooks=pre_llm_call; skills=temporal-awareness; " "skipped_optional_skills=missing-optional", ) @@ -1187,6 +1347,38 @@ def second(raw_args): self.assertEqual(ctx.commands, []) self.assertEqual(ctx.tools, []) + def test_rejects_duplicate_cli_command_names_before_registration(self) -> None: + @hpk.command("valdris", type="cli", description="First CLI command.") + def first(args): + return args + + @hpk.command("valdris", type=hpk.CommandType.CLI, description="Second CLI command.") + def second(args): + return args + + ctx = FakePluginCtx() + with self.assertRaisesRegex(ValueError, "duplicate CLI command"): + hpk.register_plugin(ctx, self._module(first=first, second=second)) + self.assertEqual(ctx.cli_commands, []) + self.assertEqual(ctx.tools, []) + + def test_allows_same_name_on_slash_and_cli_surfaces(self) -> None: + @hpk.command("valdris", type="slash", description="Slash command.") + def slash(raw_args): + return raw_args + + @hpk.command("valdris", type="cli", description="CLI command.") + def cli(args): + return args + + ctx = FakePluginCtx() + summary = hpk.register_plugin(ctx, self._module(slash=slash, cli=cli)) + + self.assertEqual(summary.commands, ("valdris",)) + self.assertEqual(summary.cli_commands, ("valdris",)) + self.assertEqual([entry["name"] for entry in ctx.commands], ["valdris"]) + self.assertEqual([entry["name"] for entry in ctx.cli_commands], ["valdris"]) + def test_rejects_duplicate_skill_names(self) -> None: skills = ( hpk.plugin_skill("same", "one/SKILL.md", "First", optional=True), diff --git a/uv.lock b/uv.lock index bc1530a..6b7bed7 100644 --- a/uv.lock +++ b/uv.lock @@ -87,7 +87,7 @@ wheels = [ [[package]] name = "hermes-plugin-kit" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } [package.dev-dependencies]