@craftsamo/whatsapp-tools 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.antigravity-plugin/plugin.json +12 -0
  3. package/.claude-plugin/marketplace.json +17 -0
  4. package/.claude-plugin/plugin.json +18 -0
  5. package/.codex-plugin/plugin.json +31 -0
  6. package/.mcp.json +8 -0
  7. package/LICENSE +429 -0
  8. package/README.md +569 -2
  9. package/__init__.py +36 -0
  10. package/dist/cli.js +30714 -0
  11. package/dist/mcp.js +37673 -0
  12. package/dist/opencode.js +27584 -0
  13. package/mcp_config.json +8 -0
  14. package/package.json +70 -4
  15. package/plugin.yaml +46 -0
  16. package/skills/.gitkeep +0 -0
  17. package/skills/whatsapp-account/SKILL.md +55 -0
  18. package/skills/whatsapp-actions/SKILL.md +109 -0
  19. package/skills/whatsapp-chats/SKILL.md +125 -0
  20. package/skills/whatsapp-contacts/SKILL.md +59 -0
  21. package/skills/whatsapp-count-activity/SKILL.md +63 -0
  22. package/skills/whatsapp-files/SKILL.md +112 -0
  23. package/skills/whatsapp-sends/SKILL.md +50 -0
  24. package/src/hermes/whatsapp_tools_hermes/__init__.py +5 -0
  25. package/src/hermes/whatsapp_tools_hermes/approval.py +330 -0
  26. package/src/hermes/whatsapp_tools_hermes/card.py +507 -0
  27. package/src/hermes/whatsapp_tools_hermes/context.py +34 -0
  28. package/src/hermes/whatsapp_tools_hermes/listing.py +78 -0
  29. package/src/hermes/whatsapp_tools_hermes/plugin.py +38 -0
  30. package/src/hermes/whatsapp_tools_hermes/project.py +47 -0
  31. package/src/hermes/whatsapp_tools_hermes/runtime.py +204 -0
  32. package/src/hermes/whatsapp_tools_hermes/settings.py +171 -0
  33. package/src/hermes/whatsapp_tools_hermes/skills.py +131 -0
  34. package/src/hermes/whatsapp_tools_hermes/tools.py +97 -0
@@ -0,0 +1,47 @@
1
+ """The plugin's identity and constants that differ between tool packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ #: The plugin's name: used as a log prefix, approval rule key prefix and Hermes toolset prefix.
6
+ PLUGIN_NAME = "whatsapp-tools"
7
+
8
+ #: The built command-line name used in error and log messages.
9
+ CLI_NAME = "whatsapp-tools-cli"
10
+
11
+ #: The prefix skills start with: skills named ``<SKILL_PREFIX><topic>`` are registered without it.
12
+ SKILL_PREFIX = "whatsapp-"
13
+
14
+ #: The emoji each toolset is shown with.
15
+ TOOLSET_EMOJI: dict[str, str] = {
16
+ "account": "🪪",
17
+ "chats": "💬",
18
+ "contacts": "📇",
19
+ "history": "📜",
20
+ "files": "📎",
21
+ "sends": "📤",
22
+ "actions": "🔧",
23
+ }
24
+
25
+ #: The settings the tools themselves read, beside the ones every plugin takes: each snake_case name and its type
26
+ #: (``text``, ``boolean``, ``number`` or ``list``), as in ``src/project.ts``. A setting ``a_b`` becomes the flag
27
+ #: ``--a-b`` of the command line.
28
+ OPTIONS: dict[str, str] = {}
29
+
30
+ #: How long one call of a tool may take, in seconds, for a tool that may take longer than the default.
31
+ #: ``whatsapp_history_backfill`` waits up to 300 seconds for the phone, so it gets a margin above that;
32
+ #: ``whatsapp_file_save`` may wait 150 seconds for WhatsApp's server and then 150 for the phone;
33
+ #: ``whatsapp_file_send`` ends within 280 seconds however many files it sends, and gets the same margin.
34
+ TOOL_TIMEOUT_SECONDS: dict[str, float] = {
35
+ "whatsapp_history_backfill": 330,
36
+ "whatsapp_file_save": 330,
37
+ "whatsapp_file_send": 330,
38
+ }
39
+
40
+ __all__ = [
41
+ "CLI_NAME",
42
+ "OPTIONS",
43
+ "PLUGIN_NAME",
44
+ "SKILL_PREFIX",
45
+ "TOOLSET_EMOJI",
46
+ "TOOL_TIMEOUT_SECONDS",
47
+ ]
@@ -0,0 +1,204 @@
1
+ """Where the plugin finds node, the command line it calls, and how it runs it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import threading
10
+ from collections.abc import Callable
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from . import project
16
+
17
+ #: Whether processes have groups the plugin can signal as a whole.
18
+ _POSIX = os.name == "posix"
19
+
20
+ #: How long a command line that was sent ``SIGTERM`` may keep running before it is sent ``SIGKILL``, in seconds.
21
+ TERMINATE_GRACE_SECONDS = 2
22
+
23
+ #: The repository root, where ``plugin.yaml`` sits: this file is
24
+ #: ``<root>/src/hermes/whatsapp_tools_hermes/runtime.py``.
25
+ PLUGIN_DIR = Path(__file__).resolve().parents[3]
26
+
27
+ #: The built command line, relative to ``PLUGIN_DIR``.
28
+ CLI_PATH = Path("dist") / "cli.js"
29
+
30
+ #: The folder holding ``<skill>/SKILL.md`` files, relative to ``PLUGIN_DIR``.
31
+ SKILLS_PATH = Path("skills")
32
+
33
+
34
+ class Unavailable(Exception):
35
+ """The plugin cannot offer its tools; the message says why, for one log line."""
36
+
37
+
38
+ class CliError(Exception):
39
+ """The command line could not run to its end (it timed out or could not start)."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CliResult:
44
+ """What a finished command line left behind."""
45
+
46
+ code: int
47
+ stdout: str
48
+ stderr: str
49
+
50
+
51
+ @dataclass
52
+ class Runtime:
53
+ """What the plugin needs from its install: node, the built command line and the skills folder.
54
+
55
+ ``node`` is the path found when the plugin was registered. A version manager names one
56
+ version in it and may remove that version while Hermes runs, so a call that finds it gone
57
+ looks for node again with ``find_node`` and keeps what it finds.
58
+ """
59
+
60
+ node: str
61
+ cli: Path
62
+ skills_dir: Path
63
+ find_node: Callable[[], str | None] = field(default=lambda: _find_node(), compare=False, repr=False)
64
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, compare=False, repr=False)
65
+
66
+ def run(self, args: list[str], *, stdin: str = "", timeout: float) -> CliResult:
67
+ """Run ``node <cli> <args>`` with ``stdin`` as its input, without a shell.
68
+
69
+ On POSIX the command line leads a process group of its own, which a timeout stops
70
+ as a whole. When the recorded node is gone, it is looked for again once before the
71
+ call fails.
72
+
73
+ Raises:
74
+ CliError: the command line timed out or could not be started, or node is gone.
75
+ """
76
+ node = self.node
77
+ try:
78
+ process = self._start(node, args)
79
+ except OSError as error:
80
+ if os.path.exists(node):
81
+ raise CliError(f"{project.CLI_NAME} could not be started: {error}") from error
82
+ found = self._find_again(node)
83
+ if found is None:
84
+ raise CliError(
85
+ f"{project.CLI_NAME} could not be started: node is gone from {node}; install Node.js 24 or later, "
86
+ "then restart Hermes from a shell where `node` runs"
87
+ ) from error
88
+ try:
89
+ process = self._start(found, args)
90
+ except OSError as again:
91
+ raise CliError(f"{project.CLI_NAME} could not be started: {again}") from again
92
+ try:
93
+ stdout, stderr = process.communicate(input=stdin, timeout=timeout)
94
+ except subprocess.TimeoutExpired as error:
95
+ _stop(process)
96
+ raise CliError(f"{project.CLI_NAME} did not finish within {timeout:g} seconds") from error
97
+ except BaseException:
98
+ _stop(process)
99
+ raise
100
+ return CliResult(process.returncode, stdout, stderr)
101
+
102
+ def _start(self, node: str, args: list[str]) -> subprocess.Popen[str]:
103
+ """Start ``node <cli> <args>`` with its pipes open, leading a process group of its own on POSIX."""
104
+ return subprocess.Popen(
105
+ [node, str(self.cli), *args],
106
+ stdin=subprocess.PIPE,
107
+ stdout=subprocess.PIPE,
108
+ stderr=subprocess.PIPE,
109
+ text=True,
110
+ encoding="utf-8",
111
+ errors="replace",
112
+ env=dict(os.environ),
113
+ start_new_session=_POSIX,
114
+ )
115
+
116
+ def _find_again(self, gone: str) -> str | None:
117
+ """Node found again in place of ``gone``, kept as ``node``; ``None`` when there is no other.
118
+
119
+ Calls that find the same node gone at once look for it once: a later one takes what the first kept.
120
+ """
121
+ with self._lock:
122
+ if self.node != gone:
123
+ return self.node
124
+ try:
125
+ found = self.find_node()
126
+ except Exception: # a lookup that fails finds nothing
127
+ found = None
128
+ if not found or found == gone:
129
+ return None
130
+ self.node = found
131
+ return found
132
+
133
+
134
+ def _signal_group(process: subprocess.Popen[str], signal_number: int) -> None:
135
+ """Send a signal to the child and everything it started, which share the child's process group."""
136
+ try:
137
+ os.killpg(process.pid, signal_number)
138
+ except ProcessLookupError:
139
+ pass
140
+
141
+
142
+ def _stop(process: subprocess.Popen[str]) -> None:
143
+ """Stop the child and, on POSIX, the whole process group it leads, then collect what it left.
144
+
145
+ The group gets ``SIGTERM`` and ``TERMINATE_GRACE_SECONDS`` to end, then ``SIGKILL``, so a
146
+ child process the command line started does not outlive the call.
147
+ """
148
+ try:
149
+ if _POSIX:
150
+ _signal_group(process, signal.SIGTERM)
151
+ else:
152
+ process.terminate()
153
+ try:
154
+ process.communicate(timeout=TERMINATE_GRACE_SECONDS)
155
+ except subprocess.TimeoutExpired:
156
+ if _POSIX:
157
+ _signal_group(process, signal.SIGKILL)
158
+ else:
159
+ process.kill()
160
+ try:
161
+ process.communicate(timeout=TERMINATE_GRACE_SECONDS)
162
+ except subprocess.TimeoutExpired:
163
+ # Something left the group while holding the pipes; do not wait for it, only for the child itself.
164
+ with suppress(subprocess.TimeoutExpired):
165
+ process.wait(timeout=TERMINATE_GRACE_SECONDS)
166
+ finally:
167
+ for stream in (process.stdin, process.stdout, process.stderr):
168
+ if stream is not None:
169
+ stream.close()
170
+
171
+
172
+ def _find_node() -> str | None:
173
+ """Hermes' own node when it has installed one, else the one on ``PATH``."""
174
+ try:
175
+ from hermes_constants import find_node_executable
176
+
177
+ node = find_node_executable("node")
178
+ except Exception: # any failure of Hermes' lookup falls back to PATH
179
+ node = None
180
+ return node or shutil.which("node")
181
+
182
+
183
+ def locate_runtime() -> Runtime:
184
+ """Find node and the built command line.
185
+
186
+ Raises:
187
+ Unavailable: node or ``dist/cli.js`` is missing.
188
+ """
189
+ node = _find_node()
190
+ if node is None:
191
+ raise Unavailable("node was not found; install Node.js 24 or later")
192
+ cli = PLUGIN_DIR / CLI_PATH
193
+ if not cli.is_file():
194
+ raise Unavailable(f"{cli} is missing; run `pnpm run build` in {PLUGIN_DIR}")
195
+ return Runtime(node=node, cli=cli, skills_dir=PLUGIN_DIR / SKILLS_PATH)
196
+
197
+
198
+ def agent_cwd() -> str:
199
+ """The directory the agent works in, which relative paths or contextual targets refer to."""
200
+ try:
201
+ from agent.runtime_cwd import resolve_agent_cwd
202
+ except ImportError:
203
+ return os.getcwd()
204
+ return str(resolve_agent_cwd())
@@ -0,0 +1,171 @@
1
+ """The plugin's settings, turned into the flags of the command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from . import project
10
+ from .context import PluginContext
11
+
12
+ #: The value of ``toolsets`` that turns on every toolset.
13
+ ALL_TOOLSETS = "all"
14
+
15
+
16
+ class SettingsError(ValueError):
17
+ """A setting has a value of the wrong type."""
18
+
19
+
20
+ def _names(key: str, value: Any) -> str | None:
21
+ """A list of names as the comma-separated text a flag takes; ``None`` for no names."""
22
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
23
+ raise SettingsError(f'setting "{key}" must be a list of names')
24
+ return ",".join(value) or None
25
+
26
+
27
+ def _directory_flags(ctx: PluginContext, key: str, flag: str) -> list[str]:
28
+ """Read the directory setting ``key`` and return ``[flag, value]``, or ``[]`` when absent.
29
+
30
+ Raises:
31
+ SettingsError: the setting is present but not a non-empty string.
32
+ """
33
+ value = ctx.get_config(key)
34
+ if value is None:
35
+ return []
36
+ if not isinstance(value, str) or not value.strip():
37
+ raise SettingsError(f'setting "{key}" must be a path')
38
+ return [flag, value]
39
+
40
+
41
+ def state_dir_flags(ctx: PluginContext) -> list[str]:
42
+ """Read ``state_dir`` and return ``["--state-dir", value]``, or ``[]`` when absent.
43
+
44
+ Raises:
45
+ SettingsError: the setting is present but not a non-empty string.
46
+ """
47
+ return _directory_flags(ctx, "state_dir", "--state-dir")
48
+
49
+
50
+ def download_dir_flags(ctx: PluginContext) -> list[str]:
51
+ """Read ``download_dir`` and return ``["--download-dir", value]``, or ``[]`` when absent.
52
+
53
+ Raises:
54
+ SettingsError: the setting is present but not a non-empty string.
55
+ """
56
+ return _directory_flags(ctx, "download_dir", "--download-dir")
57
+
58
+
59
+ def _is_path_item(item: Any) -> bool:
60
+ """Whether ``item`` is a non-empty string that survives the flag's comma-separated form whole."""
61
+ return isinstance(item, str) and bool(item.strip()) and "," not in item
62
+
63
+
64
+ def send_dirs_flags(ctx: PluginContext) -> list[str]:
65
+ """Read ``send_dirs`` and return ``["--send-dirs", "a,b"]``, or ``[]`` when absent or empty.
66
+
67
+ The flag is comma-separated, so a path holding a comma is refused rather than split in two.
68
+
69
+ Raises:
70
+ SettingsError: the setting is present but not a list of non-empty strings without commas.
71
+ """
72
+ value = ctx.get_config("send_dirs")
73
+ if value is None:
74
+ return []
75
+ if not isinstance(value, list) or not all(_is_path_item(item) for item in value):
76
+ raise SettingsError('setting "send_dirs" must be a list of paths without commas')
77
+ return ["--send-dirs", ",".join(value)] if value else []
78
+
79
+
80
+ def directory_flags(ctx: PluginContext) -> list[str]:
81
+ """The flags of the directory settings, which is all the ``target`` command takes."""
82
+ return state_dir_flags(ctx) + download_dir_flags(ctx) + send_dirs_flags(ctx)
83
+
84
+
85
+ def language_setting(ctx: PluginContext) -> str | None:
86
+ """The ``language`` setting, stripped; ``None`` when it is absent, blank or not a string.
87
+
88
+ It is no flag of ``settings_flags``: only ``target`` takes a language.
89
+ """
90
+ value = ctx.get_config("language")
91
+ return (value.strip() or None) if isinstance(value, str) else None
92
+
93
+
94
+ def emoji_setting(ctx: PluginContext, defaults: Mapping[str, str] | None = None) -> dict[str, str]:
95
+ """The emoji per toolset: ``defaults`` (``project.TOOLSET_EMOJI``) with string entries of ``emoji`` merged over it.
96
+
97
+ A setting that is not a ``dict``, and an entry whose key or value is not a
98
+ ``str``, are ignored so a typo never takes every tool away. It is no flag
99
+ of ``settings_flags``.
100
+ """
101
+ merged = dict(project.TOOLSET_EMOJI if defaults is None else defaults)
102
+ value = ctx.get_config("emoji")
103
+ if isinstance(value, dict):
104
+ merged.update({key: item for key, item in value.items() if isinstance(key, str) and isinstance(item, str)})
105
+ return merged
106
+
107
+
108
+ def _option_flag(name: str, kind: str, value: Any) -> list[str]:
109
+ flag = f"--{name.replace('_', '-')}"
110
+ if kind == "text":
111
+ if not isinstance(value, str):
112
+ raise SettingsError(f'setting "{name}" must be text')
113
+ return [flag, text] if (text := value.strip()) else []
114
+ if kind == "boolean":
115
+ if not isinstance(value, bool):
116
+ raise SettingsError(f'setting "{name}" must be true or false')
117
+ return [flag] if value else []
118
+ if kind == "number":
119
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
120
+ raise SettingsError(f'setting "{name}" must be a finite number')
121
+ return [flag, str(value)]
122
+ if kind == "list":
123
+ if not isinstance(value, list) or not all(isinstance(item, str) and "," not in item for item in value):
124
+ raise SettingsError(f'setting "{name}" must be a list of strings without commas')
125
+ items = [item.strip() for item in value if item.strip()]
126
+ return [flag, ",".join(items)] if items else []
127
+ raise SettingsError(f'unknown option type "{kind}" for "{name}"')
128
+
129
+
130
+ def option_flags(ctx: PluginContext, options: Mapping[str, str] | None = None) -> list[str]:
131
+ """The package's tool options as flags, with absent settings left to the command line's environment."""
132
+ flags: list[str] = []
133
+ for name, kind in (project.OPTIONS if options is None else options).items():
134
+ value = ctx.get_config(name)
135
+ if value is not None:
136
+ flags += _option_flag(name, kind, value)
137
+ return flags
138
+
139
+
140
+ def settings_flags(ctx: PluginContext) -> list[str]:
141
+ """Read the plugin's settings and return the flags that say the same.
142
+
143
+ A setting that is absent gives no flag, so the command line's own defaults
144
+ (and ``WHATSAPP_TOOLS_*`` variables) apply. A wrong type is refused rather
145
+ than ignored, since ignoring ``read_only`` would loosen what the person asked for.
146
+
147
+ Raises:
148
+ SettingsError: a setting has a value of the wrong type.
149
+ """
150
+ flags: list[str] = []
151
+
152
+ toolsets = ctx.get_config("toolsets")
153
+ if toolsets == ALL_TOOLSETS:
154
+ flags += ["--toolsets", ALL_TOOLSETS]
155
+ elif toolsets is not None and (names := _names("toolsets", toolsets)):
156
+ flags += ["--toolsets", names]
157
+
158
+ read_only = ctx.get_config("read_only")
159
+ if read_only is not None and not isinstance(read_only, bool):
160
+ raise SettingsError('setting "read_only" must be true or false')
161
+ if read_only:
162
+ flags.append("--read-only")
163
+
164
+ for key, flag in (("tools", "--tools"), ("exclude_tools", "--exclude-tools")):
165
+ value = ctx.get_config(key)
166
+ if value is not None and (names := _names(key, value)):
167
+ flags += [flag, names]
168
+
169
+ flags += directory_flags(ctx)
170
+
171
+ return [*flags, *option_flags(ctx)]
@@ -0,0 +1,131 @@
1
+ """Registering the package's skills, which sit in ``skills/<skill>/SKILL.md``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from collections.abc import Collection
9
+ from pathlib import Path
10
+
11
+ from .context import PluginContext
12
+ from . import project
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ SKILL_FILE = "SKILL.md"
17
+
18
+ #: Skills are named ``<SKILL_PREFIX><topic>``; Hermes already shows the plugin's name before the skill's.
19
+ NAME_PREFIX = project.SKILL_PREFIX
20
+
21
+ _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n((?:.*?\r?\n)?)---[ \t]*(?:\r?\n|\Z)", re.DOTALL)
22
+ _KEY_LINE = re.compile(r"^(name|description)[ \t]*:[ \t]*(.*?)[ \t]*$")
23
+ #: ``metadata:`` with no value (a comment may follow), which opens a block of keys.
24
+ _METADATA_OPEN = re.compile(r"^metadata[ \t]*:(?:[ \t]+#.*)?[ \t]*$")
25
+ #: The ``toolset`` key of a ``metadata`` block, with its raw value.
26
+ _TOOLSET_LINE = re.compile(r"^toolset[ \t]*:[ \t]*(.*?)[ \t]*$")
27
+ #: A comment line; with a blank one, it never ends a ``metadata`` block.
28
+ _COMMENT_LINE = re.compile(r"^[ \t]*#")
29
+ #: Where a YAML value stops being a plain one-line scalar this reader understands.
30
+ _UNSUPPORTED_VALUE_START = re.compile(r"^[>|\[{&*!%@`]")
31
+ _TRAILING_COMMENT = re.compile(r"[ \t]+#.*$")
32
+ #: A quoted scalar with nothing after its closing quote but an optional comment.
33
+ _SINGLE_QUOTED = re.compile(r"^'((?:[^']|'')*)'(?:[ \t]+#.*)?$")
34
+ _DOUBLE_QUOTED = re.compile(r'^"((?:[^"\\]|\\.)*)"(?:[ \t]+#.*)?$')
35
+
36
+
37
+ def _unquote(value: str) -> str | None:
38
+ """The text of a quoted scalar, or ``None`` when it is not a well-formed one."""
39
+ single = _SINGLE_QUOTED.match(value)
40
+ if single:
41
+ return single.group(1).replace("''", "'")
42
+ double = _DOUBLE_QUOTED.match(value)
43
+ if not double:
44
+ return None
45
+ try:
46
+ return json.loads(f'"{double.group(1)}"')
47
+ except json.JSONDecodeError:
48
+ return None
49
+
50
+
51
+ def _scalar(raw: str) -> str | None:
52
+ """A plain or quoted single-line scalar; anything else (block, flow, anchor, empty) reads as absent."""
53
+ if raw == "" or _UNSUPPORTED_VALUE_START.match(raw):
54
+ return None
55
+ if raw.startswith(('"', "'")):
56
+ return _unquote(raw)
57
+ return _TRAILING_COMMENT.sub("", raw).strip() or None
58
+
59
+
60
+ def read_frontmatter(text: str) -> dict[str, str]:
61
+ """The ``name``, ``description`` and ``toolset`` of a skill's frontmatter; only plain and quoted single-line values count.
62
+
63
+ ``toolset`` is the skill's link to a toolset, read from ``metadata.toolset``
64
+ (a ``toolset`` key among the ``metadata`` block's own keys). A top-level
65
+ ``toolset``, a flow ``metadata: {...}`` and deeper nesting give no link.
66
+ """
67
+ match = _FRONTMATTER.match(text)
68
+ found: dict[str, str] = {}
69
+ in_metadata = False
70
+ indent: str | None = None
71
+ for line in (match.group(1) if match else "").splitlines():
72
+ if not line.strip() or _COMMENT_LINE.match(line):
73
+ continue
74
+ if line[0] in " \t":
75
+ if not in_metadata:
76
+ continue
77
+ if indent is None:
78
+ indent = line[: len(line) - len(line.lstrip(" \t"))]
79
+ link = _TOOLSET_LINE.match(line[len(indent) :]) if line.startswith(indent) else None
80
+ value = _scalar(link.group(1)) if link else None
81
+ if value is not None:
82
+ found["toolset"] = value
83
+ continue
84
+ in_metadata = bool(_METADATA_OPEN.match(line))
85
+ indent = None
86
+ key_line = _KEY_LINE.match(line)
87
+ value = _scalar(key_line.group(2)) if key_line else None
88
+ if key_line and value is not None:
89
+ found[key_line.group(1)] = value
90
+ return found
91
+
92
+
93
+ def short_name(name: str) -> str:
94
+ """A skill's name without its leading prefix: e.g. ``example-things`` is shown as ``whatsapp-tools:things``."""
95
+ return name.removeprefix(project.SKILL_PREFIX) or name
96
+
97
+
98
+ def register_skills(ctx: PluginContext, skills_dir: Path, toolsets: Collection[str] | None = None) -> None:
99
+ """Register every ``<skills_dir>/<skill>/SKILL.md``; a missing folder registers nothing.
100
+
101
+ A skill is named by its frontmatter ``name`` (its folder's name when it has
102
+ none). When two folders give the same name, the first in folder order wins,
103
+ even when that one is left out by ``toolsets``. With ``toolsets`` given, a
104
+ skill linked to a toolset not in it is not registered; an unlinked skill
105
+ always is, and ``None`` registers every skill. A skill Hermes refuses is
106
+ logged and skipped, so it never costs the tools nor claims its name.
107
+ """
108
+ if not skills_dir.is_dir():
109
+ return
110
+ registered: set[str] = set()
111
+ filtered: set[str] = set()
112
+ for folder in sorted(path for path in skills_dir.iterdir() if path.is_dir()):
113
+ path = folder / SKILL_FILE
114
+ try:
115
+ text = path.read_text(encoding="utf-8-sig")
116
+ except OSError:
117
+ continue
118
+ found = read_frontmatter(text)
119
+ name = short_name(found.get("name", folder.name))
120
+ if name in registered or name in filtered:
121
+ continue
122
+ link = found.get("toolset")
123
+ if toolsets is not None and link is not None and link not in toolsets:
124
+ filtered.add(name)
125
+ continue
126
+ try:
127
+ ctx.register_skill(name, path, description=found.get("description", ""))
128
+ except (ValueError, OSError) as error:
129
+ logger.warning("%s: skill %s was not registered: %s", project.PLUGIN_NAME, folder.name, error)
130
+ continue
131
+ registered.add(name)
@@ -0,0 +1,97 @@
1
+ """Registering the tools the command line lists, and running one through it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable, Mapping
7
+ from typing import Any
8
+
9
+ from .approval import PIN_ARGUMENT, parse_pin, run_arguments
10
+ from .context import PluginContext
11
+ from .listing import ToolInfo
12
+ from . import project
13
+ from .runtime import CliError, Runtime, agent_cwd
14
+
15
+ #: How long one call may take, in seconds: a ceiling above what a tool call is expected to take.
16
+ #: ``project.TOOL_TIMEOUT_SECONDS`` gives a longer one to the tools that need it.
17
+ RUN_TIMEOUT_SECONDS = 150
18
+
19
+ #: The toolset name of a tool in ``hermes tools``: the package's toolset after this prefix.
20
+ TOOLSET_PREFIX = f"{project.PLUGIN_NAME}-"
21
+
22
+ #: The emoji each toolset is shown with.
23
+ TOOLSET_EMOJI = project.TOOLSET_EMOJI
24
+
25
+
26
+ def error_result(message: str) -> str:
27
+ """The JSON a failed call returns to the model."""
28
+ return json.dumps({"error": message})
29
+
30
+
31
+ def _read_document(stdout: str) -> dict[str, Any] | None:
32
+ """The JSON object a command line wrote, or ``None`` when it wrote something else."""
33
+ try:
34
+ document = json.loads(stdout)
35
+ except json.JSONDecodeError:
36
+ return None
37
+ return document if isinstance(document, dict) else None
38
+
39
+
40
+ def _failure(code: int, stdout: str, stderr: str) -> str:
41
+ """The error result of a command line that did not succeed: its ``<code>: <message>`` and details line, or its stderr."""
42
+ error = (_read_document(stdout) or {}).get("error")
43
+ if isinstance(error, dict) and isinstance(error.get("code"), str) and isinstance(error.get("message"), str):
44
+ message = f"{error['code']}: {error['message']}"
45
+ details = error.get("details")
46
+ if isinstance(details, dict):
47
+ message = f"{message}\n" + json.dumps(details, ensure_ascii=False, separators=(",", ":"))
48
+ return error_result(message)
49
+ detail = stderr.strip() or f"exit status {code}"
50
+ return error_result(f"{project.CLI_NAME} failed: {detail}")
51
+
52
+
53
+ def make_handler(runtime: Runtime, tool_id: str, flags: list[str]) -> Callable[..., str]:
54
+ """The handler Hermes calls for one tool: it takes the arguments as one dict and never raises.
55
+
56
+ The pin the pin hook set (``PIN_ARGUMENT``) is taken out of the arguments and, when it is a
57
+ pin, passed to ``run`` as ``--pin``; anything else runs as if there were none.
58
+ """
59
+
60
+ def handler(args: dict[str, Any], **kwargs: Any) -> str:
61
+ try:
62
+ pin = parse_pin(args.get(PIN_ARGUMENT))
63
+ result = runtime.run(
64
+ ["run", tool_id, "--cwd", agent_cwd(), *flags, *([f"--pin={pin}"] if pin else [])],
65
+ stdin=json.dumps(run_arguments(args)),
66
+ timeout=project.TOOL_TIMEOUT_SECONDS.get(tool_id, RUN_TIMEOUT_SECONDS),
67
+ )
68
+ except CliError as error:
69
+ return error_result(str(error))
70
+ except Exception as error: # a tool call must reach the model as a result, whatever went wrong
71
+ return error_result(f"{tool_id} could not run: {error}")
72
+ document = _read_document(result.stdout)
73
+ if result.code == 0 and document is not None and "result" in document:
74
+ return result.stdout.strip()
75
+ return _failure(result.code, result.stdout, result.stderr)
76
+
77
+ return handler
78
+
79
+
80
+ def register_tools(
81
+ ctx: PluginContext,
82
+ runtime: Runtime,
83
+ tools: list[ToolInfo],
84
+ flags: list[str],
85
+ emoji: Mapping[str, str] | None = None,
86
+ ) -> None:
87
+ """Register each listed tool under its own ID, grouped in one Hermes toolset per package toolset."""
88
+ toolset_emoji = TOOLSET_EMOJI if emoji is None else emoji
89
+ for tool in tools:
90
+ ctx.register_tool(
91
+ name=tool.id,
92
+ toolset=TOOLSET_PREFIX + tool.toolset.replace("_", "-"),
93
+ schema={"name": tool.id, "description": tool.description, "parameters": tool.input_schema},
94
+ handler=make_handler(runtime, tool.id, flags),
95
+ emoji=toolset_emoji.get(tool.toolset, ""),
96
+ description=tool.title,
97
+ )