Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions contributors/emails/andrexibiza@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
andrexibiza
# campaign authorship
69 changes: 66 additions & 3 deletions hermes_cli/subcommands/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@
from typing import Callable


def _add_profile_flag(parser) -> None:
parser.add_argument(
"--profile",
default="",
help="Profile whose webhook subscriptions to manage (default: active profile)",
)


def _add_json_flag(parser) -> None:
parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON (secrets are masked on read)",
)


def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
"""Attach the ``webhook`` subcommand to ``subparsers``."""
# =========================================================================
Expand All @@ -17,12 +33,12 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
webhook_parser = subparsers.add_parser(
"webhook",
help="Manage dynamic webhook subscriptions",
description="Create, list, and remove webhook subscriptions for event-driven agent activation",
description="Create, list, and manage webhook subscriptions for event-driven agent activation",
)
webhook_subparsers = webhook_parser.add_subparsers(dest="webhook_action")

wh_sub = webhook_subparsers.add_parser(
"subscribe", aliases=["add"], help="Create a webhook subscription"
"subscribe", aliases=["add", "create"], help="Create a webhook subscription"
)
wh_sub.add_argument("name", help="Route name (used in URL: /webhooks/<name>)")
wh_sub.add_argument(
Expand Down Expand Up @@ -62,15 +78,61 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
"payload is passed as JSON on stdin; empty stdout, [SILENT], or a "
"nonzero exit code ignores the webhook.",
)
wh_sub.add_argument(
"--replace",
action="store_true",
help="Overwrite an existing route of the same name (default: error)",
)
_add_profile_flag(wh_sub)

webhook_subparsers.add_parser(
wh_list = webhook_subparsers.add_parser(
"list", aliases=["ls"], help="List all dynamic subscriptions"
)
_add_profile_flag(wh_list)
_add_json_flag(wh_list)

wh_show = webhook_subparsers.add_parser(
"show", help="Show one subscription's details"
)
wh_show.add_argument("name", help="Subscription name to show")
_add_profile_flag(wh_show)
_add_json_flag(wh_show)

wh_upd = webhook_subparsers.add_parser(
"update", help="Patch fields on an existing subscription"
)
wh_upd.add_argument("name", help="Subscription name to update")
wh_upd.add_argument("--prompt", default="", help="New prompt template")
wh_upd.add_argument("--events", default="", help="New comma-separated events")
wh_upd.add_argument("--description", default="", help="New description")
wh_upd.add_argument("--skills", default="", help="New comma-separated skills")
wh_upd.add_argument("--deliver", default="", help="New delivery target")
wh_upd.add_argument("--deliver-chat-id", default="", help="New target chat ID")
_add_profile_flag(wh_upd)

wh_enable = webhook_subparsers.add_parser(
"enable", help="Enable a disabled subscription"
)
wh_enable.add_argument("name", help="Subscription name to enable")
_add_profile_flag(wh_enable)

wh_disable = webhook_subparsers.add_parser(
"disable", help="Disable a subscription without removing it"
)
wh_disable.add_argument("name", help="Subscription name to disable")
_add_profile_flag(wh_disable)

wh_rotate = webhook_subparsers.add_parser(
"rotate-secret", help="Rotate a subscription's HMAC secret"
)
wh_rotate.add_argument("name", help="Subscription name to rotate")
_add_profile_flag(wh_rotate)

wh_rm = webhook_subparsers.add_parser(
"remove", aliases=["rm"], help="Remove a subscription"
)
wh_rm.add_argument("name", help="Subscription name to remove")
_add_profile_flag(wh_rm)

wh_test = webhook_subparsers.add_parser(
"test", help="Send a test POST to a webhook route"
Expand All @@ -79,5 +141,6 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
wh_test.add_argument(
"--payload", default="", help="JSON payload to send (default: test payload)"
)
_add_profile_flag(wh_test)

webhook_parser.set_defaults(func=cmd_webhook)
187 changes: 173 additions & 14 deletions hermes_cli/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,37 @@ def _hermes_home() -> Path:
return get_hermes_home()


def _subscriptions_path() -> Path:
return _hermes_home() / _SUBSCRIPTIONS_FILENAME
def _active_profile_name() -> str:
"""Return the effective profile name for webhook subscription storage."""
try:
from hermes_cli.profiles import get_active_profile_name
name = get_active_profile_name()
if name and name != "custom":
return name
except Exception:
pass
return "default"


def _profile_root(profile: str | None) -> Path:
"""Return the profile-scoped Hermes root for subscription storage.

The default profile lives directly in HERMES_HOME; named profiles live
under ``HERMES_HOME/profiles/<name>``.
"""
home = _hermes_home()
name = (profile or "").strip() or _active_profile_name()
if not name or name == "default":
return home
return home / "profiles" / name


def _subscriptions_path(profile: str | None = None) -> Path:
return _profile_root(profile) / _SUBSCRIPTIONS_FILENAME


def _load_subscriptions() -> Dict[str, dict]:
path = _subscriptions_path()
def _load_subscriptions(profile: str | None = None) -> Dict[str, dict]:
path = _subscriptions_path(profile)
if not path.exists():
return {}
try:
Expand All @@ -48,8 +73,8 @@ def _load_subscriptions() -> Dict[str, dict]:
return {}


def _save_subscriptions(subs: Dict[str, dict]) -> None:
path = _subscriptions_path()
def _save_subscriptions(subs: Dict[str, dict], profile: str | None = None) -> None:
path = _subscriptions_path(profile)
path.parent.mkdir(parents=True, exist_ok=True)
# webhook_subscriptions.json contains per-route HMAC secrets — write
# via tempfile + chmod 0o600 before the atomic rename so a permissive
Expand Down Expand Up @@ -137,22 +162,60 @@ def _require_webhook_enabled() -> bool:
return False


def _args_profile(args) -> str | None:
profile = getattr(args, "profile", "") or ""
return profile.strip() or None


def _redact_secret(value: str) -> str:
if not isinstance(value, str) or not value:
return value
if len(value) <= 8:
return "***"
return value[:4] + "..." + value[-4:]


def _route_for_json(name: str, route: dict, base_url: str) -> dict:
return {
"name": name,
"description": route.get("description", ""),
"enabled": route.get("enabled", True),
"events": route.get("events", []),
"deliver": route.get("deliver", "log"),
"deliver_only": bool(route.get("deliver_only")),
"script": route.get("script"),
"url": f"{base_url}/webhooks/{name}",
# Secret is never emitted verbatim on read; masked for safety.
"secret_masked": _redact_secret(str(route.get("secret", ""))),
}


def webhook_command(args):
"""Entry point for 'hermes webhook' subcommand."""
sub = getattr(args, "webhook_action", None)

if not sub:
print("Usage: hermes webhook {subscribe|list|remove|test}")
print("Usage: hermes webhook {subscribe|list|show|update|enable|disable|rotate-secret|remove|test}")
print("Run 'hermes webhook --help' for details.")
return

if not _require_webhook_enabled():
return

if sub in {"subscribe", "add"}:
if sub in {"subscribe", "add", "create"}:
_cmd_subscribe(args)
elif sub in {"list", "ls"}:
_cmd_list(args)
elif sub == "show":
_cmd_show(args)
elif sub == "update":
_cmd_update(args)
elif sub == "enable":
_cmd_set_enabled(args, True)
elif sub == "disable":
_cmd_set_enabled(args, False)
elif sub == "rotate-secret":
_cmd_rotate_secret(args)
elif sub in {"remove", "rm"}:
_cmd_remove(args)
elif sub == "test":
Expand All @@ -165,8 +228,16 @@ def _cmd_subscribe(args):
print(f"Error: Invalid name '{name}'. Use lowercase alphanumeric with hyphens/underscores.")
return

subs = _load_subscriptions()
profile = _args_profile(args)
subs = _load_subscriptions(profile)
is_update = name in subs
if is_update and not getattr(args, "replace", False):
print(
f"Error: A subscription named '{name}' already exists. "
f"Use 'hermes webhook update {name}' to patch fields, or pass "
f"--replace to overwrite."
)
return

secret = args.secret or secrets.token_urlsafe(32)
events = [e.strip() for e in args.events.split(",")] if args.events else []
Expand Down Expand Up @@ -198,7 +269,7 @@ def _cmd_subscribe(args):
route["deliver_extra"] = {"chat_id": args.deliver_chat_id}

subs[name] = route
_save_subscriptions(subs)
_save_subscriptions(subs, profile)

base_url = _get_webhook_base_url()
status = "Updated" if is_update else "Created"
Expand All @@ -224,14 +295,101 @@ def _cmd_subscribe(args):
print(" The gateway must be running to receive events (hermes gateway run).\n")


def _cmd_show(args):
name = args.name.strip().lower()
subs = _load_subscriptions(_args_profile(args))
if name not in subs:
print(f" No subscription named '{name}'.")
return
route = subs[name]
base_url = _get_webhook_base_url()
if getattr(args, "json", False):
print(json.dumps(_route_for_json(name, route, base_url), indent=2))
return
events = ", ".join(route.get("events", [])) or "(all)"
deliver = route.get("deliver", "log")
if route.get("deliver_only"):
deliver = f"{deliver} (direct — no agent)"
enabled = "yes" if route.get("enabled", True) else "no"
print(f"\n ◆ {name}")
if route.get("description"):
print(f" {route['description']}")
print(f" URL: {base_url}/webhooks/{name}")
print(f" Enabled: {enabled}")
print(f" Events: {events}")
print(f" Deliver: {deliver}")
print(f" Secret: {_redact_secret(str(route.get('secret', ''))) or '(none)'}")
if route.get("script"):
print(f" Script: {route['script']}")
if route.get("skills"):
print(f" Skills: {', '.join(route['skills'])}")
if route.get("prompt"):
print(f" Prompt: {route['prompt'][:80]}")
print()


def _cmd_update(args):
name = args.name.strip().lower()
subs = _load_subscriptions(_args_profile(args))
if name not in subs:
print(f" No subscription named '{name}'.")
return
route = subs[name]
if args.prompt:
route["prompt"] = args.prompt
if args.events:
route["events"] = [e.strip() for e in args.events.split(",") if e.strip()]
if args.description:
route["description"] = args.description
if args.skills:
route["skills"] = [s.strip() for s in args.skills.split(",") if s.strip()]
if args.deliver:
route["deliver"] = args.deliver
if args.deliver_chat_id:
route["deliver_extra"] = {"chat_id": args.deliver_chat_id}
_save_subscriptions(subs, _args_profile(args))
print(f" Updated webhook subscription: {name}")


def _cmd_set_enabled(args, enabled: bool):
name = args.name.strip().lower()
subs = _load_subscriptions(_args_profile(args))
if name not in subs:
print(f" No subscription named '{name}'.")
return
subs[name]["enabled"] = enabled
_save_subscriptions(subs, _args_profile(args))
action = "Enabled" if enabled else "Disabled"
print(f" {action} webhook subscription: {name}")


def _cmd_rotate_secret(args):
name = args.name.strip().lower()
subs = _load_subscriptions(_args_profile(args))
if name not in subs:
print(f" No subscription named '{name}'.")
return
new_secret = secrets.token_urlsafe(32)
subs[name]["secret"] = new_secret
_save_subscriptions(subs, _args_profile(args))
print(f" Rotated secret for {name}.")
print(f" New secret (shown once): {new_secret}")
print(" Store this in your provider's webhook configuration.")


def _cmd_list(args):
subs = _load_subscriptions()
profile = _args_profile(args)
subs = _load_subscriptions(profile)
if not subs:
print(" No dynamic webhook subscriptions.")
print(" Create one with: hermes webhook subscribe <name>")
return

base_url = _get_webhook_base_url()
if getattr(args, "json", False):
payload = [_route_for_json(n, r, base_url) for n, r in subs.items()]
print(json.dumps(payload, indent=2))
return
print(f"\n {len(subs)} webhook subscription(s):\n")
for name, route in subs.items():
events = ", ".join(route.get("events", [])) or "(all)"
Expand All @@ -252,22 +410,23 @@ def _cmd_list(args):

def _cmd_remove(args):
name = args.name.strip().lower()
subs = _load_subscriptions()
profile = _args_profile(args)
subs = _load_subscriptions(profile)

if name not in subs:
print(f" No subscription named '{name}'.")
print(" Note: Static routes from config.yaml cannot be removed here.")
return

del subs[name]
_save_subscriptions(subs)
_save_subscriptions(subs, profile)
print(f" Removed webhook subscription: {name}")


def _cmd_test(args):
"""Send a test POST to a webhook route."""
name = args.name.strip().lower()
subs = _load_subscriptions()
subs = _load_subscriptions(_args_profile(args))

if name not in subs:
print(f" No subscription named '{name}'.")
Expand Down
Loading
Loading