@craftsamo/signal-tools 0.0.0 → 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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.antigravity-plugin/plugin.json +12 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +430 -4
- package/__init__.py +36 -0
- package/dist/cli.js +28639 -0
- package/dist/mcp.js +36626 -0
- package/dist/opencode.js +26103 -0
- package/mcp.json +10 -0
- package/mcp_config.json +8 -0
- package/package.json +64 -4
- package/plugin.json +13 -0
- package/plugin.yaml +34 -0
- package/skills/.gitkeep +0 -0
- package/skills/signal-account/SKILL.md +42 -0
- package/skills/signal-actions/SKILL.md +52 -0
- package/skills/signal-chats/SKILL.md +71 -0
- package/skills/signal-contacts/SKILL.md +28 -0
- package/skills/signal-files/SKILL.md +86 -0
- package/skills/signal-sends/SKILL.md +41 -0
- package/src/hermes/signal_tools_hermes/__init__.py +5 -0
- package/src/hermes/signal_tools_hermes/approval.py +78 -0
- package/src/hermes/signal_tools_hermes/context.py +31 -0
- package/src/hermes/signal_tools_hermes/listing.py +75 -0
- package/src/hermes/signal_tools_hermes/plugin.py +36 -0
- package/src/hermes/signal_tools_hermes/project.py +22 -0
- package/src/hermes/signal_tools_hermes/runtime.py +161 -0
- package/src/hermes/signal_tools_hermes/settings.py +113 -0
- package/src/hermes/signal_tools_hermes/skills.py +95 -0
- package/src/hermes/signal_tools_hermes/tools.py +79 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The tools the command line offers for the current settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import project
|
|
10
|
+
from .runtime import CliError, Runtime, Unavailable
|
|
11
|
+
|
|
12
|
+
#: How long ``list`` may take, in seconds.
|
|
13
|
+
LIST_TIMEOUT_SECONDS = 30
|
|
14
|
+
|
|
15
|
+
#: The write class of a tool that changes nothing.
|
|
16
|
+
READ_CLASS = "read"
|
|
17
|
+
|
|
18
|
+
#: The fields of a listed tool that are text.
|
|
19
|
+
_TEXT_FIELDS = ("id", "title", "description", "toolset", "class")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ToolInfo:
|
|
24
|
+
"""One tool as ``list`` describes it."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
title: str
|
|
28
|
+
description: str
|
|
29
|
+
toolset: str
|
|
30
|
+
write_class: str
|
|
31
|
+
input_schema: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_listing(cls, entry: Any) -> ToolInfo:
|
|
35
|
+
"""Read one element of the ``list`` array.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
Unavailable: the element lacks a field the plugin needs.
|
|
39
|
+
"""
|
|
40
|
+
if (
|
|
41
|
+
not isinstance(entry, dict)
|
|
42
|
+
or not all(isinstance(entry.get(field), str) for field in _TEXT_FIELDS)
|
|
43
|
+
or not isinstance(entry.get("input_schema"), dict)
|
|
44
|
+
):
|
|
45
|
+
raise Unavailable(f"{project.CLI_NAME} listed a tool the plugin cannot read: {entry!r}")
|
|
46
|
+
return cls(
|
|
47
|
+
id=entry["id"],
|
|
48
|
+
title=entry["title"],
|
|
49
|
+
description=entry["description"],
|
|
50
|
+
toolset=entry["toolset"],
|
|
51
|
+
write_class=entry["class"],
|
|
52
|
+
input_schema=entry["input_schema"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def list_tools(runtime: Runtime, flags: list[str]) -> list[ToolInfo]:
|
|
57
|
+
"""Ask the command line which tools the settings select.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
Unavailable: the command line failed or answered with something else than a tool list.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
result = runtime.run(["list", *flags], timeout=LIST_TIMEOUT_SECONDS)
|
|
64
|
+
except CliError as error:
|
|
65
|
+
raise Unavailable(str(error)) from error
|
|
66
|
+
if result.code != 0:
|
|
67
|
+
detail = result.stderr.strip() or f"exit status {result.code}"
|
|
68
|
+
raise Unavailable(f"`{project.CLI_NAME} list` failed: {detail}")
|
|
69
|
+
try:
|
|
70
|
+
listing = json.loads(result.stdout)
|
|
71
|
+
except json.JSONDecodeError as error:
|
|
72
|
+
raise Unavailable(f"`{project.CLI_NAME} list` did not answer with JSON") from error
|
|
73
|
+
if not isinstance(listing, list):
|
|
74
|
+
raise Unavailable(f"`{project.CLI_NAME} list` did not answer with a list")
|
|
75
|
+
return [ToolInfo.from_listing(entry) for entry in listing]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The plugin's entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .approval import register_approval
|
|
8
|
+
from .context import PluginContext
|
|
9
|
+
from .listing import list_tools
|
|
10
|
+
from . import project
|
|
11
|
+
from .runtime import Unavailable, locate_runtime
|
|
12
|
+
from .settings import SettingsError, directory_flags, settings_flags
|
|
13
|
+
from .skills import register_skills
|
|
14
|
+
from .tools import register_tools
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(ctx: PluginContext) -> None:
|
|
20
|
+
"""Register the tools the settings select, the approval hook and the skills.
|
|
21
|
+
|
|
22
|
+
The tools run in the package's command line, so node and ``dist/cli.js``
|
|
23
|
+
must be there and ``list`` must answer. When they are not, or a setting is
|
|
24
|
+
wrong, this logs one warning and registers nothing; it never raises.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
runtime = locate_runtime()
|
|
28
|
+
flags = settings_flags(ctx)
|
|
29
|
+
tools = list_tools(runtime, flags)
|
|
30
|
+
except (Unavailable, SettingsError) as error:
|
|
31
|
+
logger.warning("%s is not available and registers nothing: %s", project.PLUGIN_NAME, error)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
register_tools(ctx, runtime, tools, flags)
|
|
35
|
+
register_approval(ctx, runtime, tools, directory_flags(ctx))
|
|
36
|
+
register_skills(ctx, runtime.skills_dir)
|
|
@@ -0,0 +1,22 @@
|
|
|
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 = "signal-tools"
|
|
7
|
+
|
|
8
|
+
#: The built command-line name used in error and log messages.
|
|
9
|
+
CLI_NAME = "signal-tools-cli"
|
|
10
|
+
|
|
11
|
+
#: The prefix skills start with: skills named ``<SKILL_PREFIX><topic>`` are registered without it.
|
|
12
|
+
SKILL_PREFIX = "signal-"
|
|
13
|
+
|
|
14
|
+
#: The emoji each toolset is shown with; empty by default until a project adds its own.
|
|
15
|
+
TOOLSET_EMOJI: dict[str, str] = {"account": "🪪"}
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CLI_NAME",
|
|
19
|
+
"PLUGIN_NAME",
|
|
20
|
+
"SKILL_PREFIX",
|
|
21
|
+
"TOOLSET_EMOJI",
|
|
22
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
from contextlib import suppress
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import project
|
|
14
|
+
|
|
15
|
+
#: Whether processes have groups the plugin can signal as a whole.
|
|
16
|
+
_POSIX = os.name == "posix"
|
|
17
|
+
|
|
18
|
+
#: How long a command line that was sent ``SIGTERM`` may keep running before it is sent ``SIGKILL``, in seconds.
|
|
19
|
+
TERMINATE_GRACE_SECONDS = 2
|
|
20
|
+
|
|
21
|
+
#: The repository root, where ``plugin.yaml`` sits: this file is
|
|
22
|
+
#: ``<root>/src/hermes/signal_tools_hermes/runtime.py``.
|
|
23
|
+
PLUGIN_DIR = Path(__file__).resolve().parents[3]
|
|
24
|
+
|
|
25
|
+
#: The built command line, relative to ``PLUGIN_DIR``.
|
|
26
|
+
CLI_PATH = Path("dist") / "cli.js"
|
|
27
|
+
|
|
28
|
+
#: The folder holding ``<skill>/SKILL.md`` files, relative to ``PLUGIN_DIR``.
|
|
29
|
+
SKILLS_PATH = Path("skills")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Unavailable(Exception):
|
|
33
|
+
"""The plugin cannot offer its tools; the message says why, for one log line."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CliError(Exception):
|
|
37
|
+
"""The command line could not run to its end (it timed out or could not start)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class CliResult:
|
|
42
|
+
"""What a finished command line left behind."""
|
|
43
|
+
|
|
44
|
+
code: int
|
|
45
|
+
stdout: str
|
|
46
|
+
stderr: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Runtime:
|
|
51
|
+
"""What the plugin needs from its install: node, the built command line and the skills folder."""
|
|
52
|
+
|
|
53
|
+
node: str
|
|
54
|
+
cli: Path
|
|
55
|
+
skills_dir: Path
|
|
56
|
+
|
|
57
|
+
def run(self, args: list[str], *, stdin: str = "", timeout: float) -> CliResult:
|
|
58
|
+
"""Run ``node <cli> <args>`` with ``stdin`` as its input, without a shell.
|
|
59
|
+
|
|
60
|
+
On POSIX the command line leads a process group of its own, which a timeout stops
|
|
61
|
+
as a whole.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
CliError: the command line timed out or could not be started.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
process = subprocess.Popen(
|
|
68
|
+
[self.node, str(self.cli), *args],
|
|
69
|
+
stdin=subprocess.PIPE,
|
|
70
|
+
stdout=subprocess.PIPE,
|
|
71
|
+
stderr=subprocess.PIPE,
|
|
72
|
+
text=True,
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
errors="replace",
|
|
75
|
+
env=dict(os.environ),
|
|
76
|
+
start_new_session=_POSIX,
|
|
77
|
+
)
|
|
78
|
+
except OSError as error:
|
|
79
|
+
raise CliError(f"{project.CLI_NAME} could not be started: {error}") from error
|
|
80
|
+
try:
|
|
81
|
+
stdout, stderr = process.communicate(input=stdin, timeout=timeout)
|
|
82
|
+
except subprocess.TimeoutExpired as error:
|
|
83
|
+
_stop(process)
|
|
84
|
+
raise CliError(f"{project.CLI_NAME} did not finish within {timeout:g} seconds") from error
|
|
85
|
+
except BaseException:
|
|
86
|
+
_stop(process)
|
|
87
|
+
raise
|
|
88
|
+
return CliResult(process.returncode, stdout, stderr)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _signal_group(process: subprocess.Popen[str], signal_number: int) -> None:
|
|
92
|
+
"""Send a signal to the child and everything it started, which share the child's process group."""
|
|
93
|
+
try:
|
|
94
|
+
os.killpg(process.pid, signal_number)
|
|
95
|
+
except ProcessLookupError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _stop(process: subprocess.Popen[str]) -> None:
|
|
100
|
+
"""Stop the child and, on POSIX, the whole process group it leads, then collect what it left.
|
|
101
|
+
|
|
102
|
+
The group gets ``SIGTERM`` and ``TERMINATE_GRACE_SECONDS`` to end, then ``SIGKILL``, so a
|
|
103
|
+
child process the command line started does not outlive the call.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
if _POSIX:
|
|
107
|
+
_signal_group(process, signal.SIGTERM)
|
|
108
|
+
else:
|
|
109
|
+
process.terminate()
|
|
110
|
+
try:
|
|
111
|
+
process.communicate(timeout=TERMINATE_GRACE_SECONDS)
|
|
112
|
+
except subprocess.TimeoutExpired:
|
|
113
|
+
if _POSIX:
|
|
114
|
+
_signal_group(process, signal.SIGKILL)
|
|
115
|
+
else:
|
|
116
|
+
process.kill()
|
|
117
|
+
try:
|
|
118
|
+
process.communicate(timeout=TERMINATE_GRACE_SECONDS)
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
# Something left the group while holding the pipes; do not wait for it, only for the child itself.
|
|
121
|
+
with suppress(subprocess.TimeoutExpired):
|
|
122
|
+
process.wait(timeout=TERMINATE_GRACE_SECONDS)
|
|
123
|
+
finally:
|
|
124
|
+
for stream in (process.stdin, process.stdout, process.stderr):
|
|
125
|
+
if stream is not None:
|
|
126
|
+
stream.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _find_node() -> str | None:
|
|
130
|
+
"""Hermes' own node when it has installed one, else the one on ``PATH``."""
|
|
131
|
+
try:
|
|
132
|
+
from hermes_constants import find_node_executable
|
|
133
|
+
|
|
134
|
+
node = find_node_executable("node")
|
|
135
|
+
except Exception: # any failure of Hermes' lookup falls back to PATH
|
|
136
|
+
node = None
|
|
137
|
+
return node or shutil.which("node")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def locate_runtime() -> Runtime:
|
|
141
|
+
"""Find node and the built command line.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
Unavailable: node or ``dist/cli.js`` is missing.
|
|
145
|
+
"""
|
|
146
|
+
node = _find_node()
|
|
147
|
+
if node is None:
|
|
148
|
+
raise Unavailable("node was not found; install Node.js 24 or later")
|
|
149
|
+
cli = PLUGIN_DIR / CLI_PATH
|
|
150
|
+
if not cli.is_file():
|
|
151
|
+
raise Unavailable(f"{cli} is missing; run `pnpm run build` in {PLUGIN_DIR}")
|
|
152
|
+
return Runtime(node=node, cli=cli, skills_dir=PLUGIN_DIR / SKILLS_PATH)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def agent_cwd() -> str:
|
|
156
|
+
"""The directory the agent works in, which relative paths or contextual targets refer to."""
|
|
157
|
+
try:
|
|
158
|
+
from agent.runtime_cwd import resolve_agent_cwd
|
|
159
|
+
except ImportError:
|
|
160
|
+
return os.getcwd()
|
|
161
|
+
return str(resolve_agent_cwd())
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The plugin's settings, turned into the flags of the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .context import PluginContext
|
|
8
|
+
|
|
9
|
+
#: The value of ``toolsets`` that turns on every toolset.
|
|
10
|
+
ALL_TOOLSETS = "all"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SettingsError(ValueError):
|
|
14
|
+
"""A setting has a value of the wrong type."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _names(key: str, value: Any) -> str | None:
|
|
18
|
+
"""A list of names as the comma-separated text a flag takes; ``None`` for no names."""
|
|
19
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
20
|
+
raise SettingsError(f'setting "{key}" must be a list of names')
|
|
21
|
+
return ",".join(value) or None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _directory_flags(ctx: PluginContext, key: str, flag: str) -> list[str]:
|
|
25
|
+
"""Read the directory setting ``key`` and return ``[flag, value]``, or ``[]`` when absent.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
SettingsError: the setting is present but not a non-empty string.
|
|
29
|
+
"""
|
|
30
|
+
value = ctx.get_config(key)
|
|
31
|
+
if value is None:
|
|
32
|
+
return []
|
|
33
|
+
if not isinstance(value, str) or not value.strip():
|
|
34
|
+
raise SettingsError(f'setting "{key}" must be a path')
|
|
35
|
+
return [flag, value]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def state_dir_flags(ctx: PluginContext) -> list[str]:
|
|
39
|
+
"""Read ``state_dir`` and return ``["--state-dir", value]``, or ``[]`` when absent.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
SettingsError: the setting is present but not a non-empty string.
|
|
43
|
+
"""
|
|
44
|
+
return _directory_flags(ctx, "state_dir", "--state-dir")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def download_dir_flags(ctx: PluginContext) -> list[str]:
|
|
48
|
+
"""Read ``download_dir`` and return ``["--download-dir", value]``, or ``[]`` when absent.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
SettingsError: the setting is present but not a non-empty string.
|
|
52
|
+
"""
|
|
53
|
+
return _directory_flags(ctx, "download_dir", "--download-dir")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_path_item(item: Any) -> bool:
|
|
57
|
+
"""Whether ``item`` is a non-empty string that survives the flag's comma-separated form whole."""
|
|
58
|
+
return isinstance(item, str) and bool(item.strip()) and "," not in item
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def send_dirs_flags(ctx: PluginContext) -> list[str]:
|
|
62
|
+
"""Read ``send_dirs`` and return ``["--send-dirs", "a,b"]``, or ``[]`` when absent or empty.
|
|
63
|
+
|
|
64
|
+
The flag is comma-separated, so a path holding a comma is refused rather than split in two.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
SettingsError: the setting is present but not a list of non-empty strings without commas.
|
|
68
|
+
"""
|
|
69
|
+
value = ctx.get_config("send_dirs")
|
|
70
|
+
if value is None:
|
|
71
|
+
return []
|
|
72
|
+
if not isinstance(value, list) or not all(_is_path_item(item) for item in value):
|
|
73
|
+
raise SettingsError('setting "send_dirs" must be a list of paths without commas')
|
|
74
|
+
return ["--send-dirs", ",".join(value)] if value else []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def directory_flags(ctx: PluginContext) -> list[str]:
|
|
78
|
+
"""The flags of the directory settings, which is all the ``target`` command takes."""
|
|
79
|
+
return state_dir_flags(ctx) + download_dir_flags(ctx) + send_dirs_flags(ctx)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def settings_flags(ctx: PluginContext) -> list[str]:
|
|
83
|
+
"""Read the plugin's settings and return the flags that say the same.
|
|
84
|
+
|
|
85
|
+
A setting that is absent gives no flag, so the command line's own defaults
|
|
86
|
+
(and ``SIGNAL_TOOLS_*`` variables) apply. A wrong type is refused rather
|
|
87
|
+
than ignored, since ignoring ``read_only`` would loosen what the person asked for.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
SettingsError: a setting has a value of the wrong type.
|
|
91
|
+
"""
|
|
92
|
+
flags: list[str] = []
|
|
93
|
+
|
|
94
|
+
toolsets = ctx.get_config("toolsets")
|
|
95
|
+
if toolsets == ALL_TOOLSETS:
|
|
96
|
+
flags += ["--toolsets", ALL_TOOLSETS]
|
|
97
|
+
elif toolsets is not None and (names := _names("toolsets", toolsets)):
|
|
98
|
+
flags += ["--toolsets", names]
|
|
99
|
+
|
|
100
|
+
read_only = ctx.get_config("read_only")
|
|
101
|
+
if read_only is not None and not isinstance(read_only, bool):
|
|
102
|
+
raise SettingsError('setting "read_only" must be true or false')
|
|
103
|
+
if read_only:
|
|
104
|
+
flags.append("--read-only")
|
|
105
|
+
|
|
106
|
+
for key, flag in (("tools", "--tools"), ("exclude_tools", "--exclude-tools")):
|
|
107
|
+
value = ctx.get_config(key)
|
|
108
|
+
if value is not None and (names := _names(key, value)):
|
|
109
|
+
flags += [flag, names]
|
|
110
|
+
|
|
111
|
+
flags += directory_flags(ctx)
|
|
112
|
+
|
|
113
|
+
return flags
|
|
@@ -0,0 +1,95 @@
|
|
|
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 pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .context import PluginContext
|
|
11
|
+
from . import project
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
SKILL_FILE = "SKILL.md"
|
|
16
|
+
|
|
17
|
+
#: Skills are named ``<SKILL_PREFIX><topic>``; Hermes already shows the plugin's name before the skill's.
|
|
18
|
+
NAME_PREFIX = project.SKILL_PREFIX
|
|
19
|
+
|
|
20
|
+
_FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n((?:.*?\r?\n)?)---[ \t]*(?:\r?\n|\Z)", re.DOTALL)
|
|
21
|
+
_KEY_LINE = re.compile(r"^(name|description)[ \t]*:[ \t]*(.*?)[ \t]*$")
|
|
22
|
+
#: Where a YAML value stops being a plain one-line scalar this reader understands.
|
|
23
|
+
_UNSUPPORTED_VALUE_START = re.compile(r"^[>|\[{&*!%@`]")
|
|
24
|
+
_TRAILING_COMMENT = re.compile(r"[ \t]+#.*$")
|
|
25
|
+
#: A quoted scalar with nothing after its closing quote but an optional comment.
|
|
26
|
+
_SINGLE_QUOTED = re.compile(r"^'((?:[^']|'')*)'(?:[ \t]+#.*)?$")
|
|
27
|
+
_DOUBLE_QUOTED = re.compile(r'^"((?:[^"\\]|\\.)*)"(?:[ \t]+#.*)?$')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _unquote(value: str) -> str | None:
|
|
31
|
+
"""The text of a quoted scalar, or ``None`` when it is not a well-formed one."""
|
|
32
|
+
single = _SINGLE_QUOTED.match(value)
|
|
33
|
+
if single:
|
|
34
|
+
return single.group(1).replace("''", "'")
|
|
35
|
+
double = _DOUBLE_QUOTED.match(value)
|
|
36
|
+
if not double:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(f'"{double.group(1)}"')
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _scalar(raw: str) -> str | None:
|
|
45
|
+
"""A plain or quoted single-line scalar; anything else (block, flow, anchor, empty) reads as absent."""
|
|
46
|
+
if raw == "" or _UNSUPPORTED_VALUE_START.match(raw):
|
|
47
|
+
return None
|
|
48
|
+
if raw.startswith(('"', "'")):
|
|
49
|
+
return _unquote(raw)
|
|
50
|
+
return _TRAILING_COMMENT.sub("", raw).strip() or None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_frontmatter(text: str) -> dict[str, str]:
|
|
54
|
+
"""The ``name`` and ``description`` of a skill's frontmatter; only plain and quoted single-line values count."""
|
|
55
|
+
match = _FRONTMATTER.match(text)
|
|
56
|
+
found: dict[str, str] = {}
|
|
57
|
+
for line in (match.group(1) if match else "").splitlines():
|
|
58
|
+
key_line = _KEY_LINE.match(line)
|
|
59
|
+
value = _scalar(key_line.group(2)) if key_line else None
|
|
60
|
+
if key_line and value is not None:
|
|
61
|
+
found[key_line.group(1)] = value
|
|
62
|
+
return found
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def short_name(name: str) -> str:
|
|
66
|
+
"""A skill's name without its leading prefix: e.g. ``example-things`` is shown as ``signal-tools:things``."""
|
|
67
|
+
return name.removeprefix(project.SKILL_PREFIX) or name
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def register_skills(ctx: PluginContext, skills_dir: Path) -> None:
|
|
71
|
+
"""Register every ``<skills_dir>/<skill>/SKILL.md``; a missing folder registers nothing.
|
|
72
|
+
|
|
73
|
+
A skill is named by its frontmatter ``name`` (its folder's name when it has
|
|
74
|
+
none). When two folders give the same name, the first in folder order wins.
|
|
75
|
+
A skill Hermes refuses is logged and skipped, so it never costs the tools.
|
|
76
|
+
"""
|
|
77
|
+
if not skills_dir.is_dir():
|
|
78
|
+
return
|
|
79
|
+
registered: set[str] = set()
|
|
80
|
+
for folder in sorted(path for path in skills_dir.iterdir() if path.is_dir()):
|
|
81
|
+
path = folder / SKILL_FILE
|
|
82
|
+
try:
|
|
83
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
found = read_frontmatter(text)
|
|
87
|
+
name = short_name(found.get("name", folder.name))
|
|
88
|
+
if name in registered:
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
ctx.register_skill(name, path, description=found.get("description", ""))
|
|
92
|
+
except (ValueError, OSError) as error:
|
|
93
|
+
logger.warning("%s: skill %s was not registered: %s", project.PLUGIN_NAME, folder.name, error)
|
|
94
|
+
continue
|
|
95
|
+
registered.add(name)
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .context import PluginContext
|
|
10
|
+
from .listing import ToolInfo
|
|
11
|
+
from . import project
|
|
12
|
+
from .runtime import CliError, Runtime, agent_cwd
|
|
13
|
+
|
|
14
|
+
#: How long one call may take, in seconds: a ceiling above what a tool call is expected to take.
|
|
15
|
+
RUN_TIMEOUT_SECONDS = 150
|
|
16
|
+
|
|
17
|
+
#: The toolset name of a tool in ``hermes tools``: the package's toolset after this prefix.
|
|
18
|
+
TOOLSET_PREFIX = f"{project.PLUGIN_NAME}-"
|
|
19
|
+
|
|
20
|
+
#: The emoji each toolset is shown with.
|
|
21
|
+
TOOLSET_EMOJI = project.TOOLSET_EMOJI
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def error_result(message: str) -> str:
|
|
25
|
+
"""The JSON a failed call returns to the model."""
|
|
26
|
+
return json.dumps({"error": message})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_document(stdout: str) -> dict[str, Any] | None:
|
|
30
|
+
"""The JSON object a command line wrote, or ``None`` when it wrote something else."""
|
|
31
|
+
try:
|
|
32
|
+
document = json.loads(stdout)
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
return None
|
|
35
|
+
return document if isinstance(document, dict) else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _failure(code: int, stdout: str, stderr: str) -> str:
|
|
39
|
+
"""The error result of a command line that did not succeed: its ``<code>: <message>``, or its stderr."""
|
|
40
|
+
error = (_read_document(stdout) or {}).get("error")
|
|
41
|
+
if isinstance(error, dict) and isinstance(error.get("code"), str) and isinstance(error.get("message"), str):
|
|
42
|
+
return error_result(f"{error['code']}: {error['message']}")
|
|
43
|
+
detail = stderr.strip() or f"exit status {code}"
|
|
44
|
+
return error_result(f"{project.CLI_NAME} failed: {detail}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def make_handler(runtime: Runtime, tool_id: str, flags: list[str]) -> Callable[..., str]:
|
|
48
|
+
"""The handler Hermes calls for one tool: it takes the arguments as one dict and never raises."""
|
|
49
|
+
|
|
50
|
+
def handler(args: dict[str, Any], **kwargs: Any) -> str:
|
|
51
|
+
try:
|
|
52
|
+
result = runtime.run(
|
|
53
|
+
["run", tool_id, "--cwd", agent_cwd(), *flags],
|
|
54
|
+
stdin=json.dumps(args),
|
|
55
|
+
timeout=RUN_TIMEOUT_SECONDS,
|
|
56
|
+
)
|
|
57
|
+
except CliError as error:
|
|
58
|
+
return error_result(str(error))
|
|
59
|
+
except Exception as error: # a tool call must reach the model as a result, whatever went wrong
|
|
60
|
+
return error_result(f"{tool_id} could not run: {error}")
|
|
61
|
+
document = _read_document(result.stdout)
|
|
62
|
+
if result.code == 0 and document is not None and "result" in document:
|
|
63
|
+
return result.stdout.strip()
|
|
64
|
+
return _failure(result.code, result.stdout, result.stderr)
|
|
65
|
+
|
|
66
|
+
return handler
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register_tools(ctx: PluginContext, runtime: Runtime, tools: list[ToolInfo], flags: list[str]) -> None:
|
|
70
|
+
"""Register each listed tool under its own ID, grouped in one Hermes toolset per package toolset."""
|
|
71
|
+
for tool in tools:
|
|
72
|
+
ctx.register_tool(
|
|
73
|
+
name=tool.id,
|
|
74
|
+
toolset=TOOLSET_PREFIX + tool.toolset.replace("_", "-"),
|
|
75
|
+
schema={"name": tool.id, "description": tool.description, "parameters": tool.input_schema},
|
|
76
|
+
handler=make_handler(runtime, tool.id, flags),
|
|
77
|
+
emoji=TOOLSET_EMOJI.get(tool.toolset, ""),
|
|
78
|
+
description=tool.title,
|
|
79
|
+
)
|