@tiens.nguyen/gu-cli 1.0.739 → 1.0.741
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/gonext_agent_chat.py +126 -3
- package/gu-cli.mjs +58 -0
- package/gu-repl.mjs +173 -9
- package/gu_hooks.py +231 -0
- package/gu_mcp.py +486 -0
- package/gu_skills.py +93 -0
- package/hooks-command.mjs +71 -0
- package/hooks-config.mjs +172 -0
- package/mcp-command.mjs +324 -0
- package/mcp-config.mjs +329 -0
- package/package.json +9 -1
- package/skills.mjs +175 -0
- package/terminal-layout.mjs +48 -0
package/gu_hooks.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Hooks for the gu agent (task #270): commands gu runs at fixed moments of a turn.
|
|
2
|
+
|
|
3
|
+
onStart the first turn of a gu session (gu started, or /reset)
|
|
4
|
+
onAsk every question, before the agent starts on it
|
|
5
|
+
onToolDone after a tool the matcher names (gu's tool names: edit_file, grep_repo, mcp__…)
|
|
6
|
+
onTurnEnd after the answer has been sent
|
|
7
|
+
|
|
8
|
+
gu's own names and gu's own tool names, deliberately not Claude Code's. The worker hands the agent
|
|
9
|
+
the hooks that apply (hooks-config.mjs: yours, plus a project's only once approved) and the
|
|
10
|
+
session this turn belongs to.
|
|
11
|
+
|
|
12
|
+
A HOOK IS A COMMAND. It gets one JSON object on stdin and may answer on stdout:
|
|
13
|
+
{"additionalContext": "..."} added for the model (onStart, onAsk; onToolDone: after the result)
|
|
14
|
+
plain text the same as additionalContext
|
|
15
|
+
exit code 2 stop: onAsk stops the turn, onToolDone tells the model; stderr is why
|
|
16
|
+
A hook that fails any other way, or runs past its timeout, is a warning — never a failed turn.
|
|
17
|
+
|
|
18
|
+
WHAT A HOOK SEES: its own env on a short whitelist of this process's (the worker's env carries gu's
|
|
19
|
+
credentials), plus GU_PROJECT_DIR and GU_HOOK_EVENT. It runs in the project folder.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
EVENTS = ("onStart", "onAsk", "onToolDone", "onTurnEnd")
|
|
31
|
+
DEFAULT_TIMEOUT_S = 30
|
|
32
|
+
CONTEXT_CAP_CHARS = 6000
|
|
33
|
+
_ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP", "SHELL", "USER",
|
|
34
|
+
"USERNAME", "LOGNAME", "TERM", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "USERPROFILE",
|
|
35
|
+
"PROGRAMDATA", "PATHEXT", "COMSPEC", "VIRTUAL_ENV", "PYTHONIOENCODING")
|
|
36
|
+
_PLAIN_MATCHER = re.compile(r"^[A-Za-z0-9_\-| ,]*$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def matches(matcher: str | None, value: str) -> bool:
|
|
40
|
+
"""Empty or "*" = everything. Letters, digits, _ - and | = an exact list. Anything else = a regex."""
|
|
41
|
+
m = (matcher or "").strip()
|
|
42
|
+
if m in ("", "*"):
|
|
43
|
+
return True
|
|
44
|
+
if _PLAIN_MATCHER.match(m):
|
|
45
|
+
return value in {x.strip() for x in re.split(r"[|,]", m) if x.strip()}
|
|
46
|
+
try:
|
|
47
|
+
return re.search(m, value) is not None
|
|
48
|
+
except re.error:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HookResult:
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.context: list[str] = []
|
|
55
|
+
self.blocked: str | None = None
|
|
56
|
+
self.warnings: list[str] = []
|
|
57
|
+
self.ran = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def text(self) -> str:
|
|
61
|
+
t = "\n\n".join(c for c in self.context if c.strip())
|
|
62
|
+
return t[:CONTEXT_CAP_CHARS] + ("\n… [hook output cut]" if len(t) > CONTEXT_CAP_CHARS else "")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HookRunner:
|
|
66
|
+
def __init__(self, hooks: dict, cwd: str | None, session_id: str,
|
|
67
|
+
log: Callable[[str], None] = lambda s: None):
|
|
68
|
+
self.hooks = hooks if isinstance(hooks, dict) else {}
|
|
69
|
+
self.cwd = cwd if cwd and os.path.isdir(cwd) else None
|
|
70
|
+
self.session_id = session_id or "unknown"
|
|
71
|
+
self.log = log
|
|
72
|
+
|
|
73
|
+
def has(self, event: str) -> bool:
|
|
74
|
+
return bool(self.hooks.get(event))
|
|
75
|
+
|
|
76
|
+
def matcher_union(self, event: str) -> list[str]:
|
|
77
|
+
return [g.get("matcher", "") for g in self.hooks.get(event) or [] if isinstance(g, dict)]
|
|
78
|
+
|
|
79
|
+
def _env(self, event: str, extra: dict | None) -> dict:
|
|
80
|
+
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
|
|
81
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
82
|
+
env["GU_PROJECT_DIR"] = self.cwd or ""
|
|
83
|
+
env["GU_HOOK_EVENT"] = event
|
|
84
|
+
for k, v in (extra or {}).items():
|
|
85
|
+
env[str(k)] = str(v)
|
|
86
|
+
return env
|
|
87
|
+
|
|
88
|
+
def run(self, event: str, payload: dict, match_value: str = "") -> HookResult:
|
|
89
|
+
res = HookResult()
|
|
90
|
+
if event not in EVENTS:
|
|
91
|
+
return res
|
|
92
|
+
body = {"event": event, "session_id": self.session_id, "cwd": self.cwd or "", **payload}
|
|
93
|
+
data = json.dumps(body, ensure_ascii=False, default=str)
|
|
94
|
+
for group in self.hooks.get(event) or []:
|
|
95
|
+
if not isinstance(group, dict) or not matches(group.get("matcher"), match_value):
|
|
96
|
+
continue
|
|
97
|
+
for h in group.get("hooks") or []:
|
|
98
|
+
if not isinstance(h, dict) or (h.get("type") or "command") != "command" or not h.get("command"):
|
|
99
|
+
continue
|
|
100
|
+
self._one(event, h, data, res)
|
|
101
|
+
return res
|
|
102
|
+
|
|
103
|
+
def _one(self, event: str, h: dict, data: str, res: HookResult) -> None:
|
|
104
|
+
cmd = str(h["command"])
|
|
105
|
+
args = [str(a) for a in (h.get("args") or [])]
|
|
106
|
+
timeout = float(h.get("timeout") or DEFAULT_TIMEOUT_S)
|
|
107
|
+
label = os.path.basename(args[-1] if args else cmd.split()[0])
|
|
108
|
+
t0 = time.time()
|
|
109
|
+
res.ran += 1
|
|
110
|
+
try:
|
|
111
|
+
p = subprocess.run([cmd, *args] if args else cmd, shell=not args, input=data,
|
|
112
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
113
|
+
timeout=timeout, cwd=self.cwd, env=self._env(event, h.get("env")))
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
res.warnings.append(f"{event} hook {label} took longer than {timeout:.0f}s and was stopped")
|
|
116
|
+
self.log(f"hook {event} {label}: timeout {timeout:.0f}s")
|
|
117
|
+
return
|
|
118
|
+
except OSError as e:
|
|
119
|
+
res.warnings.append(f"{event} hook {label} could not start: {e.strerror or e}")
|
|
120
|
+
self.log(f"hook {event} {label}: could not start")
|
|
121
|
+
return
|
|
122
|
+
ms = int((time.time() - t0) * 1000)
|
|
123
|
+
out = (p.stdout or "").strip()
|
|
124
|
+
err = (p.stderr or "").strip()
|
|
125
|
+
# Sizes only: a hook's output is conversation content, and the log is content-free (#249).
|
|
126
|
+
self.log(f"hook {event} {label}: exit {p.returncode} in {ms}ms, {len(out)} chars out")
|
|
127
|
+
if p.returncode == 2:
|
|
128
|
+
res.blocked = err or f"the {event} hook {label} said no"
|
|
129
|
+
return
|
|
130
|
+
if p.returncode != 0:
|
|
131
|
+
tail = err.splitlines()[-1][:200] if err else ""
|
|
132
|
+
res.warnings.append(f"{event} hook {label} failed (exit {p.returncode})" + (f": {tail}" if tail else ""))
|
|
133
|
+
return
|
|
134
|
+
if not out:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
obj = json.loads(out)
|
|
138
|
+
except ValueError:
|
|
139
|
+
res.context.append(out) # plain text = context
|
|
140
|
+
return
|
|
141
|
+
if isinstance(obj, dict):
|
|
142
|
+
if str(obj.get("decision", "")).lower() == "block":
|
|
143
|
+
res.blocked = str(obj.get("reason") or err or f"the {event} hook {label} said no")
|
|
144
|
+
return
|
|
145
|
+
ctx = obj.get("additionalContext")
|
|
146
|
+
if isinstance(ctx, str) and ctx.strip():
|
|
147
|
+
res.context.append(ctx)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- onStart: once per session ---------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _state_path() -> str:
|
|
153
|
+
return os.path.join(os.path.expanduser("~"), ".gonext", "hooks-state.json")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_new_session(cwd: str, session_id: str, path: str | None = None) -> bool:
|
|
157
|
+
"""True the first time this session is seen for this folder — and records it."""
|
|
158
|
+
path = path or _state_path()
|
|
159
|
+
key = os.path.realpath(cwd or "")
|
|
160
|
+
try:
|
|
161
|
+
with open(path, encoding="utf-8") as fh:
|
|
162
|
+
state = json.load(fh)
|
|
163
|
+
if not isinstance(state, dict):
|
|
164
|
+
state = {}
|
|
165
|
+
except (OSError, ValueError):
|
|
166
|
+
state = {}
|
|
167
|
+
if state.get(key) == session_id:
|
|
168
|
+
return False
|
|
169
|
+
state[key] = session_id
|
|
170
|
+
try:
|
|
171
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
172
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
173
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
174
|
+
json.dump(state, fh, indent=2)
|
|
175
|
+
os.replace(tmp, path)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---- onToolDone: wrapping the agent's tools ---------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def wrap_tools(tools: list, runner: HookRunner, clip: int = 4000) -> int:
|
|
184
|
+
"""Run onToolDone after every tool a matcher names. Returns how many tools were wrapped. Hook
|
|
185
|
+
context is appended to the tool's result; a block (exit 2) is appended as a message."""
|
|
186
|
+
if not runner.has("onToolDone"):
|
|
187
|
+
return 0
|
|
188
|
+
matchers = runner.matcher_union("onToolDone")
|
|
189
|
+
n = 0
|
|
190
|
+
for t in tools:
|
|
191
|
+
name = getattr(t, "name", "")
|
|
192
|
+
if not name or name == "final_answer" or not any(matches(m, name) for m in matchers):
|
|
193
|
+
continue
|
|
194
|
+
orig = t.forward
|
|
195
|
+
order = list((getattr(t, "inputs", None) or {}).keys())
|
|
196
|
+
|
|
197
|
+
def forward(*pos, _orig=orig, _name=name, _order=order, **kw):
|
|
198
|
+
result = _orig(*pos, **kw)
|
|
199
|
+
args = dict(kw)
|
|
200
|
+
for k, v in zip(_order, pos):
|
|
201
|
+
args.setdefault(k, v)
|
|
202
|
+
text = str(result)
|
|
203
|
+
r = runner.run("onToolDone", {"tool_name": _name, "tool_input": args,
|
|
204
|
+
"tool_result": text[:clip]}, match_value=_name)
|
|
205
|
+
extra = []
|
|
206
|
+
if r.blocked:
|
|
207
|
+
extra.append(f"[onToolDone hook] {r.blocked}")
|
|
208
|
+
if r.text:
|
|
209
|
+
extra.append(f"[onToolDone hook]\n{r.text}")
|
|
210
|
+
return f"{result}\n\n" + "\n".join(extra) if extra and isinstance(result, str) else result
|
|
211
|
+
|
|
212
|
+
t.forward = forward
|
|
213
|
+
n += 1
|
|
214
|
+
return n
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def from_cfg(cfg: dict, log: Callable[[str], None]) -> HookRunner | None:
|
|
218
|
+
hooks = cfg.get("hooks")
|
|
219
|
+
if not isinstance(hooks, dict) or not any(hooks.get(e) for e in EVENTS):
|
|
220
|
+
return None
|
|
221
|
+
sess = cfg.get("hookSession") if isinstance(cfg.get("hookSession"), dict) else {}
|
|
222
|
+
cwd = (cfg.get("activeWorkspace") or "").strip() or None
|
|
223
|
+
if sess.get("id"):
|
|
224
|
+
return HookRunner(hooks, cwd, str(sess["id"]), log)
|
|
225
|
+
# No terminal session (the web, or a terminal older than hooks). The job id would be a NEW
|
|
226
|
+
# "session" every turn — onStart on every question — so onStart is skipped, and the other
|
|
227
|
+
# events share one id per day, which keeps the memory hook's steps together.
|
|
228
|
+
import datetime
|
|
229
|
+
runner = HookRunner(hooks, cwd, "gu-" + datetime.date.today().isoformat(), log)
|
|
230
|
+
runner.hooks = {k: v for k, v in runner.hooks.items() if k != "onStart"}
|
|
231
|
+
return runner
|