Skip to content

Commit 54b151e

Browse files
Merge pull request #10 from offendingcommit/feat/slash-command-registration
feat: register plugin slash commands declaratively
2 parents 97899c7 + 98ca906 commit 54b151e

6 files changed

Lines changed: 455 additions & 19 deletions

File tree

README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# hermes-plugin-kit
22

3-
> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct tools, hooks, skills, validation, and safe logging, baked in.
3+
> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct commands, tools, hooks, skills, validation, and safe logging, baked in.
44
55
[![test](https://github.com/offendingcommit/hermes-plugin-kit/actions/workflows/test.yml/badge.svg)](https://github.com/offendingcommit/hermes-plugin-kit/actions/workflows/test.yml)
66
![python](https://img.shields.io/badge/python-3.11%2B-blue)
77

88
`hermes-plugin-kit` is a tiny, dependency-free helper for authoring plugins for
9-
[hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate a tool
10-
with `@tool` or a lifecycle callback with `@hook`, then use `register_plugin` to
11-
register tools, hooks, and plugin-owned skills together. Existing tool-only
9+
[hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate a slash
10+
command with `@command`, a tool with `@tool`, or a lifecycle callback with
11+
`@hook`, then use `register_plugin` to register commands, tools, hooks, and
12+
plugin-owned skills together. Existing tool-only
1213
plugins can keep using `register_all`; the LLM-facing schema,
1314
argument validation, structured logging, and the JSON result envelope are all
1415
generated for you — correctly, every time.
@@ -131,13 +132,18 @@ That's it. `discord_read_thread` is registered with a `parameters`-wrapped schem
131132
self-documenting description, required-argument validation, logging, and the JSON
132133
envelope — none of which you had to write.
133134

134-
## Hooks and plugin skills
135+
## Commands, hooks, and plugin skills
135136

136137
Use the lifecycle entrypoint when a plugin provides more than tools:
137138

138139
```python
139140
from pathlib import Path
140-
from hermes_plugin_kit import hook, plugin_skill, register_plugin
141+
from hermes_plugin_kit import command, hook, plugin_skill, register_plugin
142+
143+
@command("valdris-status", args_hint="<scope>")
144+
def valdris_status(raw_args):
145+
"""Show the current Valdris plugin status."""
146+
return build_status(raw_args)
141147

142148
@hook("pre_llm_call")
143149
def inject_context(**kwargs):
@@ -156,6 +162,13 @@ def register(ctx):
156162
return register_plugin(ctx, __name__, skills=SKILLS)
157163
```
158164

165+
`@command` requires a bare lowercase kebab-case name without the leading slash.
166+
Its handler receives the trailing command text unchanged and may return
167+
`str | None` synchronously or asynchronously. The optional `args_hint` is
168+
forwarded to Hermes for native command pickers. Command logs include only the
169+
command name, elapsed time, result type, and argument character count, never
170+
the raw arguments.
171+
159172
`@hook` forwards Hermes keyword arguments and return values unchanged. It logs
160173
only the hook name, elapsed time, result type, and supplied `session_id` or
161174
`task_id`; callback payloads and exception messages are never logged. Exceptions

hermes_plugin_kit/__init__.py

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""hermes-plugin-kit — convention-correct tool registration for hermes-agent plugins.
1+
"""hermes-plugin-kit — convention-correct surface registration for Hermes plugins.
22
33
Reach for ``@tool`` + ``register_all`` and every hermes tool convention is applied
44
for you, so the classes of bug that bite hand-written plugins cannot recur:
@@ -61,6 +61,7 @@ def register(ctx):
6161

6262
__all__ = [
6363
"tool",
64+
"command",
6465
"hook",
6566
"plugin_skill",
6667
"register_plugin",
@@ -86,10 +87,12 @@ def register(ctx):
8687
]
8788

8889
_SPEC_ATTR = "_hpk_tool_spec"
90+
_COMMAND_SPEC_ATTR = "_hpk_command_spec"
8991
_HOOK_SPEC_ATTR = "_hpk_hook_spec"
9092
_REDACT_HINTS = ("token", "secret", "password", "passwd", "api_key", "apikey", "auth")
9193
_MAX_LOG_CHARS = 200
9294
_TOOL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
95+
_COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
9396
_AGENT_LOOP_TOOL_NAMES = frozenset({"todo", "memory", "session_search", "delegate_task"})
9497
_RESERVED_NAMESPACE_PREFIXES = ("memory_",)
9598
_SKILL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
@@ -127,6 +130,7 @@ class RegistrationSummary:
127130
hooks: tuple[str, ...] = ()
128131
skills: tuple[str, ...] = ()
129132
skipped_optional_skills: tuple[str, ...] = ()
133+
commands: tuple[str, ...] = ()
130134

131135

132136
class MediaType(str, Enum):
@@ -396,6 +400,106 @@ def _safe_context(kwargs: dict[str, Any]) -> dict[str, Any]:
396400
# The decorator
397401
# ---------------------------------------------------------------------------
398402

403+
def command(
404+
name: str,
405+
description: str | None = None,
406+
args_hint: str = "",
407+
) -> Callable:
408+
"""Mark and instrument a Hermes in-session slash command.
409+
410+
``name`` is the bare command name without a leading slash. The wrapped
411+
handler receives the original ``raw_args`` string and returns ``str | None``.
412+
Synchronous and asynchronous handlers preserve their native callable shape.
413+
"""
414+
if not isinstance(name, str) or not _COMMAND_NAME_RE.fullmatch(name):
415+
raise ValueError(
416+
"command name must be a bare lowercase kebab-case name matching "
417+
f"{_COMMAND_NAME_RE.pattern!r}"
418+
)
419+
if description is not None and not isinstance(description, str):
420+
raise TypeError("command description must be a string or None")
421+
if not isinstance(args_hint, str):
422+
raise TypeError("command args_hint must be a string")
423+
clean_args_hint = args_hint.strip()
424+
425+
def decorate(fn: Callable) -> Callable:
426+
explicit_description = (description or "").strip()
427+
doc = explicit_description or (inspect.getdoc(fn) or "").strip()
428+
if not doc:
429+
raise ValueError(
430+
f"@command {name!r}: a description is required "
431+
"(docstring or description=)."
432+
)
433+
log = logging.getLogger(fn.__module__ or "hermes_plugin_kit")
434+
435+
def log_invocation(raw_args: str) -> float:
436+
started = time.perf_counter()
437+
try:
438+
args_chars = len(raw_args)
439+
except TypeError:
440+
args_chars = len(str(raw_args))
441+
log.debug("%s: invoked; args_chars=%d", name, args_chars)
442+
return started
443+
444+
def log_failure(started: float, exc: Exception) -> None:
445+
log.warning(
446+
"%s: handler raised; elapsed_ms=%.2f; error_type=%s",
447+
name,
448+
(time.perf_counter() - started) * 1000,
449+
type(exc).__name__,
450+
)
451+
452+
def log_success(started: float, result: Any) -> None:
453+
log.info(
454+
"%s: ok; elapsed_ms=%.2f; result=%s",
455+
name,
456+
(time.perf_counter() - started) * 1000,
457+
type(result).__name__,
458+
)
459+
460+
if inspect.iscoroutinefunction(fn):
461+
462+
@functools.wraps(fn)
463+
async def async_wrapper(raw_args: str) -> str | None:
464+
started = log_invocation(raw_args)
465+
try:
466+
result = await fn(raw_args)
467+
except Exception as exc:
468+
log_failure(started, exc)
469+
raise
470+
log_success(started, result)
471+
return result
472+
473+
wrapper = async_wrapper
474+
else:
475+
476+
@functools.wraps(fn)
477+
def sync_wrapper(raw_args: str) -> str | None:
478+
started = log_invocation(raw_args)
479+
try:
480+
result = fn(raw_args)
481+
except Exception as exc:
482+
log_failure(started, exc)
483+
raise
484+
log_success(started, result)
485+
return result
486+
487+
wrapper = sync_wrapper
488+
489+
setattr(
490+
wrapper,
491+
_COMMAND_SPEC_ATTR,
492+
{
493+
"name": name,
494+
"description": doc,
495+
"args_hint": clean_args_hint,
496+
},
497+
)
498+
return wrapper
499+
500+
return decorate
501+
502+
399503
def hook(name: str) -> Callable:
400504
"""Mark and instrument a Hermes lifecycle hook callback.
401505
@@ -1136,7 +1240,7 @@ def register_plugin(
11361240
module: Any,
11371241
skills: tuple[PluginSkill, ...] | list[PluginSkill] = (),
11381242
) -> RegistrationSummary:
1139-
"""Register decorated tools, hooks, and declared skills from *module*.
1243+
"""Register decorated commands, tools, hooks, and skills from *module*.
11401244
11411245
Unlike the backward-compatible :func:`register_all`, this lifecycle-level
11421246
entrypoint rejects distinct declarations that share a public name. Missing
@@ -1146,9 +1250,17 @@ def register_plugin(
11461250
module = sys.modules[module]
11471251
log = logging.getLogger(getattr(module, "__name__", "hermes_plugin_kit"))
11481252

1253+
commands: dict[str, Callable] = {}
11491254
tools: dict[str, Callable] = {}
11501255
hooks: dict[str, Callable] = {}
11511256
for _, obj in inspect.getmembers(module):
1257+
command_spec = getattr(obj, _COMMAND_SPEC_ATTR, None)
1258+
if command_spec:
1259+
existing = commands.get(command_spec["name"])
1260+
if existing is not None and existing is not obj:
1261+
raise ValueError(f"duplicate command name: {command_spec['name']}")
1262+
commands[command_spec["name"]] = obj
1263+
11521264
tool_spec = getattr(obj, _SPEC_ATTR, None)
11531265
if tool_spec:
11541266
existing = tools.get(tool_spec["name"])
@@ -1187,6 +1299,18 @@ def register_plugin(
11871299
)
11881300
skipped_skills.append(name)
11891301

1302+
registered_commands: list[str] = []
1303+
for name in sorted(commands):
1304+
obj = commands[name]
1305+
spec = getattr(obj, _COMMAND_SPEC_ATTR)
1306+
ctx.register_command(
1307+
name=spec["name"],
1308+
handler=obj,
1309+
description=spec["description"],
1310+
args_hint=spec["args_hint"],
1311+
)
1312+
registered_commands.append(name)
1313+
11901314
registered_tools: list[str] = []
11911315
for name in sorted(tools):
11921316
obj = tools[name]
@@ -1209,14 +1333,16 @@ def register_plugin(
12091333
registered_skills.append(skill.name)
12101334

12111335
summary = RegistrationSummary(
1336+
commands=tuple(registered_commands),
12121337
tools=tuple(registered_tools),
12131338
hooks=tuple(registered_hooks),
12141339
skills=tuple(registered_skills),
12151340
skipped_optional_skills=tuple(skipped_skills),
12161341
)
12171342
log.info(
1218-
"hermes_plugin_kit: registered plugin lifecycle; tools=%s; hooks=%s; "
1219-
"skills=%s; skipped_optional_skills=%s",
1343+
"hermes_plugin_kit: registered plugin lifecycle; commands=%s; tools=%s; "
1344+
"hooks=%s; skills=%s; skipped_optional_skills=%s",
1345+
",".join(summary.commands) or "<none>",
12201346
",".join(summary.tools) or "<none>",
12211347
",".join(summary.hooks) or "<none>",
12221348
",".join(summary.skills) or "<none>",

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ build-backend = "setuptools.build_meta"
88

99
[project]
1010
name = "hermes-plugin-kit"
11-
version = "0.3.0"
12-
description = "Convention-correct lifecycle registration for hermes-agent plugins."
11+
version = "0.4.0"
12+
description = "Convention-correct command and lifecycle registration for hermes-agent plugins."
1313
readme = "README.md"
1414
requires-python = ">=3.11"
1515
license = { text = "MIT" }
@@ -20,10 +20,10 @@ dependencies = []
2020
[project.urls]
2121
Repository = "https://github.com/offendingcommit/hermes-plugin-kit"
2222

23-
# Runtime stays dependency-free. PyYAML is dev-only: the hermes contract tests
24-
# import hermes_cli.plugins, which transitively needs yaml. Skipped without it.
23+
# Runtime stays dependency-free. The hermes contract tests import current
24+
# upstream source, whose plugin and gateway seams transitively need these.
2525
[dependency-groups]
26-
dev = ["pyyaml"]
26+
dev = ["pyyaml", "requests==2.33.0"]
2727

2828
[tool.setuptools]
2929
packages = ["hermes_plugin_kit"]

tests/test_hermes_contract.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def _try():
4949
PluginManager,
5050
PluginManifest,
5151
VALID_HOOKS,
52+
resolve_plugin_command_result,
5253
)
5354
from tools.registry import registry # type: ignore
5455

@@ -64,6 +65,7 @@ def _try():
6465
PluginManager=PluginManager,
6566
PluginManifest=PluginManifest,
6667
VALID_HOOKS=set(VALID_HOOKS),
68+
resolve_plugin_command_result=resolve_plugin_command_result,
6769
registry=registry,
6870
)
6971

@@ -115,6 +117,15 @@ def hpk_contract_probe(args, **kwargs):
115117
_SPEC = getattr(hpk_contract_probe, "_hpk_tool_spec")
116118

117119

120+
@hpk.command(
121+
"hpk-contract-probe",
122+
description="Probe command registration.",
123+
args_hint="<value>",
124+
)
125+
async def hpk_command_contract_probe(raw_args):
126+
return f"command:{raw_args}"
127+
128+
118129
@unittest.skipUnless(_REAL is not None, "hermes-agent source not importable")
119130
class HermesContractTests(unittest.TestCase):
120131
"""Validate the kit's output against genuine hermes-agent runtime APIs."""
@@ -199,7 +210,35 @@ def contract_hook(**kwargs):
199210
)
200211
self.assertEqual(manager.find_plugin_skill("contract-plugin:probe"), path)
201212

213+
def test_command_registers_and_dispatches_through_real_plugin_context(self) -> None:
214+
manager = _REAL.PluginManager()
215+
manifest = _REAL.PluginManifest(name="contract-plugin")
216+
ctx = _REAL.PluginContext(manifest, manager)
217+
module = types.ModuleType("contract_command_plugin")
218+
module.hpk_command_contract_probe = hpk_command_contract_probe
219+
220+
summary = hpk.register_plugin(ctx, module)
221+
222+
self.assertEqual(summary.commands, ("hpk-contract-probe",))
223+
entry = manager._plugin_commands["hpk-contract-probe"]
224+
self.assertEqual(entry["description"], "Probe command registration.")
225+
self.assertEqual(entry["args_hint"], "<value>")
226+
result = entry["handler"]("exact raw args")
227+
self.assertEqual(
228+
_REAL.resolve_plugin_command_result(result),
229+
"command:exact raw args",
230+
)
231+
202232
def test_lifecycle_calls_bind_to_real_plugincontext_signatures(self) -> None:
233+
command_sig = inspect.signature(_REAL.PluginContext.register_command)
234+
command_sig.bind(
235+
None,
236+
name="probe-command",
237+
handler=lambda raw_args: raw_args,
238+
description="Probe command.",
239+
args_hint="<value>",
240+
)
241+
203242
hook_sig = inspect.signature(_REAL.PluginContext.register_hook)
204243
hook_sig.bind(None, "pre_llm_call", lambda **kwargs: None)
205244

0 commit comments

Comments
 (0)