1- """hermes-plugin-kit — convention-correct tool registration for hermes-agent plugins.
1+ """hermes-plugin-kit — convention-correct surface registration for Hermes plugins.
22
33Reach for ``@tool`` + ``register_all`` and every hermes tool convention is applied
44for 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
132136class 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+
399503def 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>" ,
0 commit comments