@craftsamo/agent-github 0.0.0-stage → 0.2.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 +237 -2
- package/__init__.py +36 -0
- package/dist/cli.js +22198 -0
- package/dist/mcp.js +32761 -0
- package/dist/opencode.js +22232 -0
- package/mcp.json +10 -0
- package/mcp_config.json +8 -0
- package/package.json +72 -4
- package/plugin.json +13 -0
- package/plugin.yaml +28 -0
- package/skills/github-issues/SKILL.md +157 -0
- package/src/hermes/agent_github_hermes/__init__.py +5 -0
- package/src/hermes/agent_github_hermes/approval.py +66 -0
- package/src/hermes/agent_github_hermes/context.py +31 -0
- package/src/hermes/agent_github_hermes/listing.py +74 -0
- package/src/hermes/agent_github_hermes/plugin.py +35 -0
- package/src/hermes/agent_github_hermes/runtime.py +159 -0
- package/src/hermes/agent_github_hermes/settings.py +53 -0
- package/src/hermes/agent_github_hermes/skills.py +94 -0
- package/src/hermes/agent_github_hermes/tools.py +85 -0
|
@@ -0,0 +1,53 @@
|
|
|
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 settings_flags(ctx: PluginContext) -> list[str]:
|
|
25
|
+
"""Read ``toolsets``, ``read_only``, ``tools`` and ``exclude_tools`` and return the flags that say the same.
|
|
26
|
+
|
|
27
|
+
A setting that is absent gives no flag, so the command line's own defaults
|
|
28
|
+
(and ``AGENT_GITHUB_*`` variables) apply. A wrong type is refused rather
|
|
29
|
+
than ignored, since ignoring ``read_only`` would loosen what the person asked for.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
SettingsError: a setting has a value of the wrong type.
|
|
33
|
+
"""
|
|
34
|
+
flags: list[str] = []
|
|
35
|
+
|
|
36
|
+
toolsets = ctx.get_config("toolsets")
|
|
37
|
+
if toolsets == ALL_TOOLSETS:
|
|
38
|
+
flags += ["--toolsets", ALL_TOOLSETS]
|
|
39
|
+
elif toolsets is not None and (names := _names("toolsets", toolsets)):
|
|
40
|
+
flags += ["--toolsets", names]
|
|
41
|
+
|
|
42
|
+
read_only = ctx.get_config("read_only")
|
|
43
|
+
if read_only is not None and not isinstance(read_only, bool):
|
|
44
|
+
raise SettingsError('setting "read_only" must be true or false')
|
|
45
|
+
if read_only:
|
|
46
|
+
flags.append("--read-only")
|
|
47
|
+
|
|
48
|
+
for key, flag in (("tools", "--tools"), ("exclude_tools", "--exclude-tools")):
|
|
49
|
+
value = ctx.get_config(key)
|
|
50
|
+
if value is not None and (names := _names(key, value)):
|
|
51
|
+
flags += [flag, names]
|
|
52
|
+
|
|
53
|
+
return flags
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
SKILL_FILE = "SKILL.md"
|
|
15
|
+
|
|
16
|
+
#: Skills are named ``github-<topic>``; Hermes already shows the plugin's name before the skill's.
|
|
17
|
+
NAME_PREFIX = "github-"
|
|
18
|
+
|
|
19
|
+
_FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n((?:.*?\r?\n)?)---[ \t]*(?:\r?\n|\Z)", re.DOTALL)
|
|
20
|
+
_KEY_LINE = re.compile(r"^(name|description)[ \t]*:[ \t]*(.*?)[ \t]*$")
|
|
21
|
+
#: Where a YAML value stops being a plain one-line scalar this reader understands.
|
|
22
|
+
_UNSUPPORTED_VALUE_START = re.compile(r"^[>|\[{&*!%@`]")
|
|
23
|
+
_TRAILING_COMMENT = re.compile(r"[ \t]+#.*$")
|
|
24
|
+
#: A quoted scalar with nothing after its closing quote but an optional comment.
|
|
25
|
+
_SINGLE_QUOTED = re.compile(r"^'((?:[^']|'')*)'(?:[ \t]+#.*)?$")
|
|
26
|
+
_DOUBLE_QUOTED = re.compile(r'^"((?:[^"\\]|\\.)*)"(?:[ \t]+#.*)?$')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _unquote(value: str) -> str | None:
|
|
30
|
+
"""The text of a quoted scalar, or ``None`` when it is not a well-formed one."""
|
|
31
|
+
single = _SINGLE_QUOTED.match(value)
|
|
32
|
+
if single:
|
|
33
|
+
return single.group(1).replace("''", "'")
|
|
34
|
+
double = _DOUBLE_QUOTED.match(value)
|
|
35
|
+
if not double:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(f'"{double.group(1)}"')
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _scalar(raw: str) -> str | None:
|
|
44
|
+
"""A plain or quoted single-line scalar; anything else (block, flow, anchor, empty) reads as absent."""
|
|
45
|
+
if raw == "" or _UNSUPPORTED_VALUE_START.match(raw):
|
|
46
|
+
return None
|
|
47
|
+
if raw.startswith(('"', "'")):
|
|
48
|
+
return _unquote(raw)
|
|
49
|
+
return _TRAILING_COMMENT.sub("", raw).strip() or None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_frontmatter(text: str) -> dict[str, str]:
|
|
53
|
+
"""The ``name`` and ``description`` of a skill's frontmatter; only plain and quoted single-line values count."""
|
|
54
|
+
match = _FRONTMATTER.match(text)
|
|
55
|
+
found: dict[str, str] = {}
|
|
56
|
+
for line in (match.group(1) if match else "").splitlines():
|
|
57
|
+
key_line = _KEY_LINE.match(line)
|
|
58
|
+
value = _scalar(key_line.group(2)) if key_line else None
|
|
59
|
+
if key_line and value is not None:
|
|
60
|
+
found[key_line.group(1)] = value
|
|
61
|
+
return found
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def short_name(name: str) -> str:
|
|
65
|
+
"""A skill's name without its leading ``github-``: ``github-issues`` is shown as ``agent-github:issues``."""
|
|
66
|
+
return name.removeprefix(NAME_PREFIX) or name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register_skills(ctx: PluginContext, skills_dir: Path) -> None:
|
|
70
|
+
"""Register every ``<skills_dir>/<skill>/SKILL.md``; a missing folder registers nothing.
|
|
71
|
+
|
|
72
|
+
A skill is named by its frontmatter ``name`` (its folder's name when it has
|
|
73
|
+
none). When two folders give the same name, the first in folder order wins.
|
|
74
|
+
A skill Hermes refuses is logged and skipped, so it never costs the tools.
|
|
75
|
+
"""
|
|
76
|
+
if not skills_dir.is_dir():
|
|
77
|
+
return
|
|
78
|
+
registered: set[str] = set()
|
|
79
|
+
for folder in sorted(path for path in skills_dir.iterdir() if path.is_dir()):
|
|
80
|
+
path = folder / SKILL_FILE
|
|
81
|
+
try:
|
|
82
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
83
|
+
except OSError:
|
|
84
|
+
continue
|
|
85
|
+
found = read_frontmatter(text)
|
|
86
|
+
name = short_name(found.get("name", folder.name))
|
|
87
|
+
if name in registered:
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
ctx.register_skill(name, path, description=found.get("description", ""))
|
|
91
|
+
except (ValueError, OSError) as error:
|
|
92
|
+
logger.warning("agent-github: skill %s was not registered: %s", folder.name, error)
|
|
93
|
+
continue
|
|
94
|
+
registered.add(name)
|
|
@@ -0,0 +1,85 @@
|
|
|
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 .runtime import CliError, Runtime, agent_cwd
|
|
12
|
+
|
|
13
|
+
#: How long one call may take, in seconds: above the 120 s the core gives each ``gh`` call.
|
|
14
|
+
RUN_TIMEOUT_SECONDS = 150
|
|
15
|
+
|
|
16
|
+
#: The toolset name of a tool in ``hermes tools``: the package's toolset after this prefix.
|
|
17
|
+
TOOLSET_PREFIX = "agent-github-"
|
|
18
|
+
|
|
19
|
+
#: The emoji each toolset is shown with.
|
|
20
|
+
TOOLSET_EMOJI = {
|
|
21
|
+
"issues": "🎫",
|
|
22
|
+
"pull_requests": "🔀",
|
|
23
|
+
"projects": "📋",
|
|
24
|
+
"repos": "📦",
|
|
25
|
+
"releases": "🏷️",
|
|
26
|
+
"actions": "⚙️",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def error_result(message: str) -> str:
|
|
31
|
+
"""The JSON a failed call returns to the model."""
|
|
32
|
+
return json.dumps({"error": message})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_document(stdout: str) -> dict[str, Any] | None:
|
|
36
|
+
"""The JSON object a command line wrote, or ``None`` when it wrote something else."""
|
|
37
|
+
try:
|
|
38
|
+
document = json.loads(stdout)
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
return None
|
|
41
|
+
return document if isinstance(document, dict) else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _failure(code: int, stdout: str, stderr: str) -> str:
|
|
45
|
+
"""The error result of a command line that did not succeed: its ``<code>: <message>``, or its stderr."""
|
|
46
|
+
error = (_read_document(stdout) or {}).get("error")
|
|
47
|
+
if isinstance(error, dict) and isinstance(error.get("code"), str) and isinstance(error.get("message"), str):
|
|
48
|
+
return error_result(f"{error['code']}: {error['message']}")
|
|
49
|
+
detail = stderr.strip() or f"exit status {code}"
|
|
50
|
+
return error_result(f"agent-github-cli 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
|
+
def handler(args: dict[str, Any], **kwargs: Any) -> str:
|
|
57
|
+
try:
|
|
58
|
+
result = runtime.run(
|
|
59
|
+
["run", tool_id, "--cwd", agent_cwd(), *flags],
|
|
60
|
+
stdin=json.dumps(args),
|
|
61
|
+
timeout=RUN_TIMEOUT_SECONDS,
|
|
62
|
+
)
|
|
63
|
+
except CliError as error:
|
|
64
|
+
return error_result(str(error))
|
|
65
|
+
except Exception as error: # a tool call must reach the model as a result, whatever went wrong
|
|
66
|
+
return error_result(f"{tool_id} could not run: {error}")
|
|
67
|
+
document = _read_document(result.stdout)
|
|
68
|
+
if result.code == 0 and document is not None and "result" in document:
|
|
69
|
+
return result.stdout.strip()
|
|
70
|
+
return _failure(result.code, result.stdout, result.stderr)
|
|
71
|
+
|
|
72
|
+
return handler
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register_tools(ctx: PluginContext, runtime: Runtime, tools: list[ToolInfo], flags: list[str]) -> None:
|
|
76
|
+
"""Register each listed tool under its own ID, grouped in one Hermes toolset per package toolset."""
|
|
77
|
+
for tool in tools:
|
|
78
|
+
ctx.register_tool(
|
|
79
|
+
name=tool.id,
|
|
80
|
+
toolset=TOOLSET_PREFIX + tool.toolset.replace("_", "-"),
|
|
81
|
+
schema={"name": tool.id, "description": tool.description, "parameters": tool.input_schema},
|
|
82
|
+
handler=make_handler(runtime, tool.id, flags),
|
|
83
|
+
emoji=TOOLSET_EMOJI.get(tool.toolset, ""),
|
|
84
|
+
description=tool.title,
|
|
85
|
+
)
|