@andespindola/crow-skills 0.7.1 → 0.8.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/README.md +6 -5
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/autoupdate.py +398 -0
- package/crow_skills/cli.py +89 -3
- package/crow_skills/events.py +111 -0
- package/crow_skills/guide.py +7 -1
- package/crow_skills/hook.py +560 -0
- package/crow_skills/integrations.py +427 -63
- package/crow_skills/progress.py +106 -14
- package/crow_skills/source.py +40 -0
- package/crow_skills/storage.py +5 -4
- package/docs/cli.md +96 -17
- package/package.json +1 -1
- package/release.json +30 -15
- package/skills/crow-behaviour/SKILL.md +3 -3
- package/skills/crow-behaviour/references/progress-ledger.md +17 -3
- package/skills/crow-behaviour/scripts/crow_ledger.py +45 -2
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""crow-skills mode, per-prompt hook and event bus (crow-event/1).
|
|
2
|
+
|
|
3
|
+
Self-contained on purpose (standard library only, no package-relative
|
|
4
|
+
imports): the integrations copy this file next to the CLI state as
|
|
5
|
+
``crow_hook.py`` and every agent CLI runs it from its hooks, so it keeps
|
|
6
|
+
working when the npm/Homebrew path of the CLI changes. The CLI imports the
|
|
7
|
+
same functions for ``crow-skills mode`` and ``crow-skills events``.
|
|
8
|
+
|
|
9
|
+
Contract for hooks: return fast, never block the agent, fail open. Any error
|
|
10
|
+
is swallowed and the hook exits 0 with no output. Nothing from a prompt,
|
|
11
|
+
message or tool input is ever stored or printed: only ids, host, cwd, model,
|
|
12
|
+
permission mode, event type, tool name, counters and timestamps.
|
|
13
|
+
|
|
14
|
+
crow_hook.py hook --host claude|codex|gemini|agy|devin --event NAME [--home DIR]
|
|
15
|
+
crow_hook.py mode on|off|status [--home DIR] [--by HOST] [--json]
|
|
16
|
+
"""
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
|
|
27
|
+
MODE_SCHEMA = "crow-mode/1"
|
|
28
|
+
EVENT_SCHEMA = "crow-event/1"
|
|
29
|
+
SESSION_SCHEMA = "crow-session/1"
|
|
30
|
+
MODES = ("on", "off")
|
|
31
|
+
DEFAULT_MODE = "on"
|
|
32
|
+
HOSTS = ("claude", "codex", "gemini", "agy", "devin")
|
|
33
|
+
MAX_EVENTS_FILE = 1024 * 1024 # bytes before rotation
|
|
34
|
+
ROTATED = 2 # events.jsonl.1 .. events.jsonl.N are kept
|
|
35
|
+
SESSION_TTL = 24 * 3600 # snapshot files untouched for this long are pruned
|
|
36
|
+
MAX_SESSIONS = 200
|
|
37
|
+
MAX_STDIN = 4 * 1024 * 1024
|
|
38
|
+
TOKEN = re.compile(r"[^A-Za-z0-9_.:@/+-]")
|
|
39
|
+
COMMAND = re.compile(r"\s*/crow[ \t]+(on|off|status)\s*\Z", re.IGNORECASE)
|
|
40
|
+
|
|
41
|
+
# Host event name -> normalised event. Unknown names are recorded as "other".
|
|
42
|
+
EVENTS = {
|
|
43
|
+
"SessionStart": "session_start", "SessionEnd": "session_end",
|
|
44
|
+
"UserPromptSubmit": "prompt", "BeforeAgent": "prompt", "PreInvocation": "prompt",
|
|
45
|
+
"PreToolUse": "tool_start", "BeforeTool": "tool_start",
|
|
46
|
+
"PostToolUse": "tool_end", "AfterTool": "tool_end", "PostToolUseFailure": "tool_error",
|
|
47
|
+
"PermissionRequest": "permission_request", "Notification": "notification",
|
|
48
|
+
"SubagentStart": "subagent_start", "SubagentStop": "subagent_stop",
|
|
49
|
+
"Stop": "stop", "AfterAgent": "stop", "PostInvocation": "stop", "StopFailure": "error",
|
|
50
|
+
"PreCompact": "compact", "PostCompact": "compact", "PreCompress": "compact", "PostCompaction": "compact",
|
|
51
|
+
"Interrupt": "interrupt",
|
|
52
|
+
}
|
|
53
|
+
PROMPT_EVENTS = ("UserPromptSubmit", "BeforeAgent", "PreInvocation")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- paths and small helpers ------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def mode_path(home):
|
|
59
|
+
return Path(home) / ".config" / "crow-skills" / "mode.json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def events_dir(home):
|
|
63
|
+
return Path(home) / ".local" / "state" / "crow-skills" / "events"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def now_iso():
|
|
67
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def token(value, limit=128):
|
|
71
|
+
"""An id, model or tool name as a short single token; anything else is dropped."""
|
|
72
|
+
if value is None or isinstance(value, (dict, list, bool)):
|
|
73
|
+
return None
|
|
74
|
+
text = TOKEN.sub("", str(value))[:limit]
|
|
75
|
+
return text or None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def plain_path(value):
|
|
79
|
+
if not isinstance(value, str) or not value.startswith("/") or len(value) > 4096:
|
|
80
|
+
return None
|
|
81
|
+
return "".join(c for c in value if c.isprintable())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def no_follow_dir(path):
|
|
85
|
+
"""Create a private directory chain; refuse when any component is a symlink."""
|
|
86
|
+
path = Path(os.path.abspath(path))
|
|
87
|
+
for part in reversed((path, *path.parents)):
|
|
88
|
+
if part.is_symlink():
|
|
89
|
+
raise OSError("symlink in state path")
|
|
90
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_json(path, data, mode=0o600):
|
|
95
|
+
path = Path(path)
|
|
96
|
+
no_follow_dir(path.parent)
|
|
97
|
+
temp = path.parent / ("." + path.name + "." + uuid.uuid4().hex)
|
|
98
|
+
try:
|
|
99
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), mode)
|
|
100
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
101
|
+
json.dump(data, handle, ensure_ascii=False, sort_keys=True, indent=2)
|
|
102
|
+
handle.write("\n")
|
|
103
|
+
handle.flush()
|
|
104
|
+
os.fsync(handle.fileno())
|
|
105
|
+
os.replace(temp, path)
|
|
106
|
+
finally:
|
|
107
|
+
if os.path.lexists(temp):
|
|
108
|
+
os.unlink(temp)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_json(path, limit=1024 * 1024):
|
|
112
|
+
path = Path(path)
|
|
113
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > limit:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- mode -----------------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def read_mode(home):
|
|
124
|
+
"""Current mode; a missing or invalid file means the default (on)."""
|
|
125
|
+
data = read_json(mode_path(home), 64 * 1024)
|
|
126
|
+
if isinstance(data, dict) and data.get("mode") in MODES:
|
|
127
|
+
return {"mode": data["mode"], "updatedAt": data.get("updatedAt"), "updatedBy": data.get("updatedBy"),
|
|
128
|
+
"source": "file", "path": str(mode_path(home))}
|
|
129
|
+
return {"mode": DEFAULT_MODE, "updatedAt": None, "updatedBy": None, "source": "default", "path": str(mode_path(home))}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def write_mode(home, value, by=None):
|
|
133
|
+
if value not in MODES:
|
|
134
|
+
raise ValueError("mode must be on or off")
|
|
135
|
+
data = {"schema": MODE_SCHEMA, "mode": value, "updatedAt": now_iso(), "updatedBy": token(by, 40) or "cli"}
|
|
136
|
+
write_json(mode_path(home), data, 0o644)
|
|
137
|
+
record(home, {"event": "mode_change", "host": token(by, 20) or "cli", "mode": value})
|
|
138
|
+
return read_mode(home)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def indicator(mode, unicode=True):
|
|
142
|
+
return ("▸▸ " if unicode else ">> ") + "crow " + mode
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def context_line(mode):
|
|
146
|
+
"""The additionalContext a prompt hook injects; short, so it costs little per prompt."""
|
|
147
|
+
if mode == "on":
|
|
148
|
+
return ("[crow-skills] modo crow: ON. Modo de orquestração permanente declarado pelo usuário: em toda tarefa "
|
|
149
|
+
"substantiva desta sessão, carregue e siga a skill crow-behaviour (conversa e ajuste trivial seguem diretos). "
|
|
150
|
+
"Vale até o usuário desligar com /crow off ou crow-skills mode off.")
|
|
151
|
+
return ("[crow-skills] modo crow: OFF. Não carregue nem aplique a skill crow-behaviour, salvo se o usuário a invocar "
|
|
152
|
+
"explicitamente neste pedido. Religar: /crow on ou crow-skills mode on.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- event bus ----------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def append_event(home, event):
|
|
158
|
+
"""Append one compact JSON line; rotate the file at MAX_EVENTS_FILE, keeping ROTATED files."""
|
|
159
|
+
directory = no_follow_dir(events_dir(home))
|
|
160
|
+
path = directory / "events.jsonl"
|
|
161
|
+
line = (json.dumps(event, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n").encode("utf-8")
|
|
162
|
+
try:
|
|
163
|
+
size = path.stat().st_size if path.exists() else 0
|
|
164
|
+
except OSError:
|
|
165
|
+
size = 0
|
|
166
|
+
if size + len(line) > MAX_EVENTS_FILE:
|
|
167
|
+
rotate(directory)
|
|
168
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
169
|
+
try:
|
|
170
|
+
os.write(fd, line) # one write per line: concurrent hooks never interleave a line
|
|
171
|
+
finally:
|
|
172
|
+
os.close(fd)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def rotate(directory):
|
|
176
|
+
try:
|
|
177
|
+
import fcntl
|
|
178
|
+
except ImportError: # pragma: no cover - POSIX only
|
|
179
|
+
return
|
|
180
|
+
lock = os.open(directory / ".rotate.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
181
|
+
try:
|
|
182
|
+
try:
|
|
183
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
184
|
+
except OSError:
|
|
185
|
+
return # another hook is rotating; this line goes to the current file
|
|
186
|
+
path = directory / "events.jsonl"
|
|
187
|
+
if not path.exists() or path.stat().st_size <= MAX_EVENTS_FILE // 2:
|
|
188
|
+
return
|
|
189
|
+
for index in range(ROTATED, 0, -1):
|
|
190
|
+
older = directory / ("events.jsonl." + str(index))
|
|
191
|
+
if index == ROTATED and older.exists():
|
|
192
|
+
older.unlink()
|
|
193
|
+
elif older.exists():
|
|
194
|
+
os.replace(older, directory / ("events.jsonl." + str(index + 1)))
|
|
195
|
+
os.replace(path, directory / "events.jsonl.1")
|
|
196
|
+
finally:
|
|
197
|
+
os.close(lock)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def session_key(host, session, cwd):
|
|
201
|
+
basis = (host or "other") + ":" + (session or ("cwd:" + (cwd or "")))
|
|
202
|
+
return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:20]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def git_branch(cwd):
|
|
206
|
+
"""Branch from .git/HEAD walking up from cwd; no subprocess, so it costs microseconds."""
|
|
207
|
+
if not cwd:
|
|
208
|
+
return None
|
|
209
|
+
path = Path(cwd)
|
|
210
|
+
for directory in (path, *path.parents):
|
|
211
|
+
dot = directory / ".git"
|
|
212
|
+
try:
|
|
213
|
+
if dot.is_file():
|
|
214
|
+
text = dot.read_text(encoding="utf-8", errors="replace").strip()
|
|
215
|
+
if text.startswith("gitdir:"):
|
|
216
|
+
gitdir = Path(text[7:].strip())
|
|
217
|
+
dot = gitdir if gitdir.is_absolute() else directory / gitdir
|
|
218
|
+
if dot.is_dir():
|
|
219
|
+
head = (dot / "HEAD").read_text(encoding="utf-8", errors="replace").strip()
|
|
220
|
+
if head.startswith("ref: refs/heads/"):
|
|
221
|
+
return token(head[16:], 120)
|
|
222
|
+
return "detached:" + head[:12] if re.fullmatch(r"[0-9a-f]{7,64}", head) else None
|
|
223
|
+
except OSError:
|
|
224
|
+
return None
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def update_session(home, event, extra=None, fallback_cwd=None):
|
|
229
|
+
"""Fold one event into the per-session snapshot (state, counters, last action)."""
|
|
230
|
+
directory = no_follow_dir(events_dir(home) / "sessions")
|
|
231
|
+
key = session_key(event.get("host"), event.get("session"), event.get("cwd"))
|
|
232
|
+
path = directory / (key + ".json")
|
|
233
|
+
try:
|
|
234
|
+
import fcntl
|
|
235
|
+
except ImportError: # pragma: no cover
|
|
236
|
+
fcntl = None
|
|
237
|
+
lock = os.open(directory / (key + ".lock"), os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
238
|
+
try:
|
|
239
|
+
if fcntl:
|
|
240
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
241
|
+
snap = read_json(path) or {}
|
|
242
|
+
if snap.get("schema") != SESSION_SCHEMA:
|
|
243
|
+
snap = {"schema": SESSION_SCHEMA, "key": key, "host": event.get("host"), "session": event.get("session"),
|
|
244
|
+
"startedAt": event.get("ts") or now_iso(), "state": "idle", "subagents": 0, "shellsStarted": 0, "tools": 0,
|
|
245
|
+
"blocked": False}
|
|
246
|
+
for field in ("cwd", "model", "permissionMode"):
|
|
247
|
+
if event.get(field):
|
|
248
|
+
snap[field] = event[field]
|
|
249
|
+
if not snap.get("cwd") and fallback_cwd:
|
|
250
|
+
snap["cwd"] = fallback_cwd # hosts that send no cwd: the hook runs in the session's directory
|
|
251
|
+
kind = event["event"]
|
|
252
|
+
if kind in ("session_start", "prompt") and snap.get("cwd"):
|
|
253
|
+
snap["branch"] = git_branch(snap["cwd"])
|
|
254
|
+
if kind == "prompt":
|
|
255
|
+
snap.update(state="working", blocked=False, currentTool=None)
|
|
256
|
+
elif kind == "tool_start":
|
|
257
|
+
snap.update(state="working", blocked=False, currentTool=event.get("tool"))
|
|
258
|
+
snap["tools"] = int(snap.get("tools") or 0) + 1
|
|
259
|
+
if event.get("background"):
|
|
260
|
+
snap["shellsStarted"] = int(snap.get("shellsStarted") or 0) + 1
|
|
261
|
+
elif kind in ("tool_end", "tool_error"):
|
|
262
|
+
snap.update(currentTool=None, blocked=False)
|
|
263
|
+
if snap.get("state") == "blocked":
|
|
264
|
+
snap["state"] = "working"
|
|
265
|
+
elif kind == "permission_request" or (kind == "notification" and event.get("blocked")):
|
|
266
|
+
snap.update(state="blocked", blocked=True)
|
|
267
|
+
elif kind == "subagent_start":
|
|
268
|
+
snap["subagents"] = int(snap.get("subagents") or 0) + 1
|
|
269
|
+
elif kind == "subagent_stop":
|
|
270
|
+
snap["subagents"] = max(0, int(snap.get("subagents") or 0) - 1)
|
|
271
|
+
elif kind == "stop":
|
|
272
|
+
snap.update(state="idle", blocked=False, currentTool=None)
|
|
273
|
+
elif kind in ("error",):
|
|
274
|
+
snap.update(state="error", blocked=False, currentTool=None)
|
|
275
|
+
elif kind == "session_end":
|
|
276
|
+
snap.update(state="ended", blocked=False, currentTool=None, endedAt=event.get("ts") or now_iso(), subagents=0)
|
|
277
|
+
if kind != "other":
|
|
278
|
+
snap["lastEvent"] = kind
|
|
279
|
+
snap["lastAction"] = kind + (":" + event["tool"] if event.get("tool") else "")
|
|
280
|
+
snap["updatedAt"] = event.get("ts") or now_iso()
|
|
281
|
+
if extra:
|
|
282
|
+
snap.update(extra)
|
|
283
|
+
write_json(path, snap)
|
|
284
|
+
return snap
|
|
285
|
+
finally:
|
|
286
|
+
os.close(lock)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def prune_sessions(home, now=None):
|
|
290
|
+
directory = events_dir(home) / "sessions"
|
|
291
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
292
|
+
return
|
|
293
|
+
now = now or time.time()
|
|
294
|
+
files = sorted(directory.glob("*.json"), key=lambda p: p.stat().st_mtime)
|
|
295
|
+
for index, path in enumerate(files):
|
|
296
|
+
try:
|
|
297
|
+
if now - path.stat().st_mtime > SESSION_TTL or index < len(files) - MAX_SESSIONS:
|
|
298
|
+
path.unlink() # the .lock stays: a hook may hold it; a lock file is empty
|
|
299
|
+
except OSError:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def record(home, fields, snapshot=False, fallback_cwd=None):
|
|
304
|
+
"""Stamp and append an event; optionally fold it into the session snapshot."""
|
|
305
|
+
event = {"v": 1, "schema": EVENT_SCHEMA, "ts": now_iso()}
|
|
306
|
+
event.update({k: v for k, v in fields.items() if v is not None})
|
|
307
|
+
append_event(home, event)
|
|
308
|
+
if snapshot:
|
|
309
|
+
return event, update_session(home, event, fallback_cwd=fallback_cwd)
|
|
310
|
+
return event, None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def sessions(home, live_only=True, now=None):
|
|
314
|
+
directory = events_dir(home) / "sessions"
|
|
315
|
+
result = []
|
|
316
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
317
|
+
return result
|
|
318
|
+
now = now or time.time()
|
|
319
|
+
for path in directory.glob("*.json"):
|
|
320
|
+
try:
|
|
321
|
+
snap = read_json(path)
|
|
322
|
+
age = now - path.stat().st_mtime
|
|
323
|
+
except OSError:
|
|
324
|
+
continue # pruned meanwhile
|
|
325
|
+
if not isinstance(snap, dict) or snap.get("schema") != SESSION_SCHEMA:
|
|
326
|
+
continue
|
|
327
|
+
if live_only and (age > SESSION_TTL or (snap.get("state") == "ended" and age > 300)):
|
|
328
|
+
continue
|
|
329
|
+
snap["ageSeconds"] = int(age)
|
|
330
|
+
result.append(snap)
|
|
331
|
+
return sorted(result, key=lambda s: s.get("updatedAt") or "", reverse=True)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def tail(home, limit=50):
|
|
335
|
+
directory = events_dir(home)
|
|
336
|
+
files = [directory / ("events.jsonl." + str(i)) for i in range(ROTATED, 0, -1)] + [directory / "events.jsonl"]
|
|
337
|
+
lines = []
|
|
338
|
+
for path in files:
|
|
339
|
+
if path.is_file() and not path.is_symlink():
|
|
340
|
+
with open(path, "rb") as handle:
|
|
341
|
+
lines.extend(handle.read().splitlines())
|
|
342
|
+
events = []
|
|
343
|
+
for raw in lines[-limit:] if limit else lines:
|
|
344
|
+
try:
|
|
345
|
+
events.append(json.loads(raw))
|
|
346
|
+
except ValueError:
|
|
347
|
+
continue
|
|
348
|
+
return events
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --- host payloads ------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def first(payload, *keys):
|
|
354
|
+
for key in keys:
|
|
355
|
+
value = payload.get(key)
|
|
356
|
+
if value not in (None, ""):
|
|
357
|
+
return value
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def genuine_claude(payload, env, home):
|
|
362
|
+
"""The claude-installed hook is also read by Devin (it imports ~/.claude/settings.json
|
|
363
|
+
hooks). Run only when the caller really is Claude Code: its transcript lives under
|
|
364
|
+
~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects); without a transcript, Claude Code
|
|
365
|
+
exports CLAUDECODE=1 to every child. Devin-only fields mean Devin."""
|
|
366
|
+
if "tool_provenance" in payload:
|
|
367
|
+
return False
|
|
368
|
+
transcript = payload.get("transcript_path")
|
|
369
|
+
if isinstance(transcript, str) and transcript:
|
|
370
|
+
roots = [Path(home) / ".claude" / "projects"]
|
|
371
|
+
if env.get("CLAUDE_CONFIG_DIR"):
|
|
372
|
+
roots.append(Path(env["CLAUDE_CONFIG_DIR"]).expanduser() / "projects")
|
|
373
|
+
real = os.path.abspath(os.path.expanduser(transcript))
|
|
374
|
+
return any(real.startswith(os.path.abspath(r) + os.sep) for r in roots)
|
|
375
|
+
return env.get("CLAUDECODE") == "1"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def foreign(host, payload, env, home):
|
|
379
|
+
"""True when this installation must stay silent because another CLI is running it."""
|
|
380
|
+
if host == "claude":
|
|
381
|
+
return not genuine_claude(payload, env, home)
|
|
382
|
+
if host == "gemini":
|
|
383
|
+
return env.get("ANTIGRAVITY_AGENT") == "1" # agy has its own hooks.json entry
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def normalise(host, raw, payload):
|
|
388
|
+
kind = EVENTS.get(raw, "other")
|
|
389
|
+
tool = token(first(payload, "tool_name", "toolName", "tool"), 64)
|
|
390
|
+
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
391
|
+
event = {
|
|
392
|
+
"host": host, "event": kind, "raw": token(raw, 40),
|
|
393
|
+
"session": token(first(payload, "session_id", "sessionId", "conversation_id", "conversationId", "thread_id"), 128),
|
|
394
|
+
"cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot")),
|
|
395
|
+
"model": token(first(payload, "model", "model_name", "modelName"), 80) if not isinstance(payload.get("model"), dict)
|
|
396
|
+
else token(payload["model"].get("id") or payload["model"].get("display_name"), 80),
|
|
397
|
+
"permissionMode": token(first(payload, "permission_mode", "permissionMode", "approval_policy", "approvalMode"), 40),
|
|
398
|
+
"tool": tool if kind in ("tool_start", "tool_end", "tool_error", "permission_request") else None,
|
|
399
|
+
"agent": token(first(payload, "agent_type", "subagent_type", "agent_name"), 64) if kind.startswith("subagent") else None,
|
|
400
|
+
}
|
|
401
|
+
if kind == "tool_start" and tool_input.get("run_in_background") is True:
|
|
402
|
+
event["background"] = True
|
|
403
|
+
if kind == "notification":
|
|
404
|
+
kind_name = str(first(payload, "notification_type", "type") or "")
|
|
405
|
+
event["blocked"] = kind_name in ("permission_prompt", "permission", "elicitation_dialog", "ToolPermission")
|
|
406
|
+
event["notification"] = token(kind_name, 40)
|
|
407
|
+
if kind == "permission_request":
|
|
408
|
+
event["blocked"] = True
|
|
409
|
+
return {k: v for k, v in event.items() if v is not None}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def process_cwd():
|
|
413
|
+
try:
|
|
414
|
+
return plain_path(os.getcwd())
|
|
415
|
+
except OSError:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def prompt_text(payload):
|
|
420
|
+
value = first(payload, "prompt", "user_prompt", "lastUserInput", "last_user_input")
|
|
421
|
+
return value if isinstance(value, str) else None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def output_for(host, raw, context=None, system=None, block=None):
|
|
425
|
+
"""The JSON each host reads from a hook's stdout, or None for no output."""
|
|
426
|
+
if block is not None:
|
|
427
|
+
if host == "agy":
|
|
428
|
+
return {"injectSteps": [{"ephemeralMessage": block + " Responda só confirmando isso em uma linha."}]}
|
|
429
|
+
return {"decision": "block", "reason": block}
|
|
430
|
+
if host == "agy":
|
|
431
|
+
steps = []
|
|
432
|
+
if context:
|
|
433
|
+
steps.append({"ephemeralMessage": context})
|
|
434
|
+
out = {"injectSteps": steps} if steps else {}
|
|
435
|
+
if system:
|
|
436
|
+
out["systemMessage"] = system
|
|
437
|
+
return out or None
|
|
438
|
+
out = {}
|
|
439
|
+
if context:
|
|
440
|
+
out["hookSpecificOutput"] = {"hookEventName": raw, "additionalContext": context}
|
|
441
|
+
if system:
|
|
442
|
+
out["systemMessage"] = system
|
|
443
|
+
return out or None
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def handle(host, raw, payload, home, env=None):
|
|
447
|
+
"""Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
|
|
448
|
+
env = os.environ if env is None else env
|
|
449
|
+
if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
|
|
450
|
+
return None
|
|
451
|
+
event = normalise(host, raw, payload)
|
|
452
|
+
current = read_mode(home)
|
|
453
|
+
if raw in PROMPT_EVENTS:
|
|
454
|
+
text = prompt_text(payload)
|
|
455
|
+
match = COMMAND.match(text) if text and len(text) < 64 else None
|
|
456
|
+
if match and host in ("codex", "devin", "agy"):
|
|
457
|
+
# CLIs without a shell slash command toggle here and block the prompt with the result.
|
|
458
|
+
wanted = match.group(1).lower()
|
|
459
|
+
if wanted != "status":
|
|
460
|
+
current = write_mode(home, wanted, host)
|
|
461
|
+
record(home, dict(event, event="mode_command", mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
|
|
462
|
+
return output_for(host, raw, block=indicator(current["mode"]) + " (crow-skills; vale para todos os CLIs a partir do próximo prompt)")
|
|
463
|
+
stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
|
|
464
|
+
if raw not in PROMPT_EVENTS and raw != "SessionStart":
|
|
465
|
+
return None
|
|
466
|
+
system = None
|
|
467
|
+
seen = (snap or {}).get("modeSeen")
|
|
468
|
+
stamp = current["mode"] + "@" + str(current["updatedAt"])
|
|
469
|
+
if host in ("codex", "gemini", "devin") and (raw == "SessionStart" or (seen and seen != stamp)):
|
|
470
|
+
system = indicator(current["mode"]) # visible line only at start or when the mode changed
|
|
471
|
+
if snap is not None and seen != stamp:
|
|
472
|
+
update_session(home, dict(stamped, event="other"), {"modeSeen": stamp, "mode": current["mode"]})
|
|
473
|
+
if raw == "SessionStart":
|
|
474
|
+
return output_for(host, raw, system=system) if system else None
|
|
475
|
+
return output_for(host, raw, context=context_line(current["mode"]), system=system)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def update_metrics(home, host, payload):
|
|
479
|
+
"""Status line payloads carry model, cost and usage: keep them in the session snapshot.
|
|
480
|
+
|
|
481
|
+
Only numbers and ids are kept; writes happen only when a value changed.
|
|
482
|
+
"""
|
|
483
|
+
if not isinstance(payload, dict):
|
|
484
|
+
return
|
|
485
|
+
session = token(first(payload, "session_id", "sessionId", "conversation_id", "conversationId"), 128)
|
|
486
|
+
cwd = plain_path(((payload.get("workspace") or {}) if isinstance(payload.get("workspace"), dict) else {}).get("current_dir")
|
|
487
|
+
or payload.get("cwd"))
|
|
488
|
+
metrics = {}
|
|
489
|
+
model = payload.get("model")
|
|
490
|
+
if isinstance(model, dict):
|
|
491
|
+
metrics["model"] = token(model.get("id") or model.get("display_name"), 80)
|
|
492
|
+
elif model:
|
|
493
|
+
metrics["model"] = token(model, 80)
|
|
494
|
+
cost = payload.get("cost")
|
|
495
|
+
if isinstance(cost, dict):
|
|
496
|
+
value = cost.get("total_cost_usd", cost.get("totalCostUsd"))
|
|
497
|
+
if isinstance(value, (int, float)):
|
|
498
|
+
metrics["costUsd"] = round(float(value), 6)
|
|
499
|
+
elif isinstance(cost, (int, float)):
|
|
500
|
+
metrics["costUsd"] = round(float(cost), 6)
|
|
501
|
+
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else (payload.get("context_window") or {})
|
|
502
|
+
if isinstance(usage, dict):
|
|
503
|
+
numbers = {k: int(v) for k, v in usage.items() if isinstance(v, (int, float)) and not isinstance(v, bool) and len(k) < 40}
|
|
504
|
+
if numbers:
|
|
505
|
+
metrics["usage"] = numbers
|
|
506
|
+
metrics = {k: v for k, v in metrics.items() if v is not None}
|
|
507
|
+
if not metrics or not (session or cwd):
|
|
508
|
+
return
|
|
509
|
+
path = events_dir(home) / "sessions" / (session_key(host, session, cwd) + ".json")
|
|
510
|
+
snap = read_json(path) or {}
|
|
511
|
+
if all(snap.get(k) == v for k, v in metrics.items()):
|
|
512
|
+
return
|
|
513
|
+
update_session(home, {"host": host, "session": session, "cwd": cwd, "event": "other", "ts": now_iso()}, metrics)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# --- entry point --------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
def main(argv=None):
|
|
519
|
+
import argparse
|
|
520
|
+
p = argparse.ArgumentParser(prog="crow_hook", description="Hook e modo do crow-skills.")
|
|
521
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
522
|
+
hook = sub.add_parser("hook")
|
|
523
|
+
hook.add_argument("--host", required=True)
|
|
524
|
+
hook.add_argument("--event", required=True)
|
|
525
|
+
hook.add_argument("--home")
|
|
526
|
+
mode = sub.add_parser("mode")
|
|
527
|
+
mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
|
|
528
|
+
mode.add_argument("--home")
|
|
529
|
+
mode.add_argument("--by")
|
|
530
|
+
mode.add_argument("--json", action="store_true")
|
|
531
|
+
args = p.parse_args(argv)
|
|
532
|
+
home = Path(args.home).expanduser() if args.home else Path.home()
|
|
533
|
+
if args.command == "mode":
|
|
534
|
+
current = read_mode(home)
|
|
535
|
+
try:
|
|
536
|
+
if args.action in MODES:
|
|
537
|
+
current = write_mode(home, args.action, args.by)
|
|
538
|
+
elif args.action == "toggle":
|
|
539
|
+
current = write_mode(home, "off" if current["mode"] == "on" else "on", args.by)
|
|
540
|
+
except OSError:
|
|
541
|
+
print("crow: não foi possível gravar " + str(mode_path(home)) + "; modo mantido: " + current["mode"])
|
|
542
|
+
return 1
|
|
543
|
+
print(json.dumps(current, ensure_ascii=False) if args.json else indicator(current["mode"]) +
|
|
544
|
+
" (vale para todos os CLIs a partir do próximo prompt)")
|
|
545
|
+
return 0
|
|
546
|
+
try:
|
|
547
|
+
raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
|
|
548
|
+
payload = json.loads(raw.decode("utf-8")) if raw.strip() and len(raw) <= MAX_STDIN else {}
|
|
549
|
+
result = handle(args.host, args.event, payload, home)
|
|
550
|
+
if args.event == "SessionStart":
|
|
551
|
+
prune_sessions(home)
|
|
552
|
+
if result:
|
|
553
|
+
sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
554
|
+
except Exception: # noqa: BLE001 - a hook must never break the agent
|
|
555
|
+
return 0
|
|
556
|
+
return 0
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
if __name__ == "__main__":
|
|
560
|
+
raise SystemExit(main())
|