@andespindola/crow-skills 0.8.0 → 0.10.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 +3 -3
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/broadcast.py +987 -0
- package/crow_skills/cli.py +176 -0
- package/crow_skills/events.py +64 -3
- package/crow_skills/hook.py +313 -14
- package/crow_skills/integrations.py +90 -22
- package/crow_skills/progress.py +55 -5
- package/crow_skills/resources.py +713 -0
- package/docs/cli.md +66 -6
- package/package.json +1 -1
- package/release.json +50 -30
- package/skills/crow-accessibility/SKILL.md +2 -0
- package/skills/crow-api-test/SKILL.md +2 -0
- package/skills/crow-backend/SKILL.md +2 -0
- package/skills/crow-behaviour/SKILL.md +2 -0
- package/skills/crow-behaviour/references/broadcast.md +45 -0
- package/skills/crow-behaviour/references/orchestration.md +6 -0
- package/skills/crow-behaviour/references/progress-ledger.md +3 -0
- package/skills/crow-behaviour/references/resources.md +44 -0
- package/skills/crow-behaviour/scripts/crow_ledger.py +75 -2
- package/skills/crow-browser/SKILL.md +2 -0
- package/skills/crow-debug/SKILL.md +2 -0
- package/skills/crow-frontend/SKILL.md +2 -0
- package/skills/crow-infra/SKILL.md +2 -0
- package/skills/crow-interface/SKILL.md +2 -0
- package/skills/crow-media/SKILL.md +2 -0
- package/skills/crow-native-mobile/SKILL.md +2 -0
- package/skills/crow-performance/SKILL.md +2 -0
- package/skills/crow-qa/SKILL.md +2 -0
- package/skills/crow-release/SKILL.md +2 -0
- package/skills/crow-reverse/SKILL.md +2 -0
- package/skills/crow-security-scan/SKILL.md +2 -0
package/crow_skills/hook.py
CHANGED
|
@@ -13,6 +13,20 @@ permission mode, event type, tool name, counters and timestamps.
|
|
|
13
13
|
|
|
14
14
|
crow_hook.py hook --host claude|codex|gemini|agy|devin --event NAME [--home DIR]
|
|
15
15
|
crow_hook.py mode on|off|status [--home DIR] [--by HOST] [--json]
|
|
16
|
+
|
|
17
|
+
On a prompt event the hook also appends one memory line to the context, only
|
|
18
|
+
when the machine is under pressure or the recommended parallelism is not
|
|
19
|
+
"parallel" (crow_resources.py, copied beside this file; cached for a few
|
|
20
|
+
seconds, so most prompts cost one small file read).
|
|
21
|
+
|
|
22
|
+
crow-broadcast (crow_broadcast.py, copied beside this file): every event also
|
|
23
|
+
keeps the session's presence (git repository, CLI pid, PATHS of files its edit
|
|
24
|
+
tools target) in the snapshot; a prompt event appends a short digest of other
|
|
25
|
+
live sessions in the same repository, only when there are any; ``--check``
|
|
26
|
+
(a synchronous PreToolUse entry for edit and shell tools) warns about another
|
|
27
|
+
live session on the same file or clone without writing anything.
|
|
28
|
+
|
|
29
|
+
crow_hook.py hook --host HOST --event PreToolUse --check [--home DIR]
|
|
16
30
|
"""
|
|
17
31
|
from datetime import datetime, timezone
|
|
18
32
|
import hashlib
|
|
@@ -152,6 +166,81 @@ def context_line(mode):
|
|
|
152
166
|
"explicitamente neste pedido. Religar: /crow on ou crow-skills mode on.")
|
|
153
167
|
|
|
154
168
|
|
|
169
|
+
# --- machine resources ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
_RESOURCES = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def resources_module():
|
|
175
|
+
"""The resource probe: the package module, or crow_resources.py beside this file.
|
|
176
|
+
|
|
177
|
+
Loaded by path because the hooks run with ``python -I`` (the script's directory is
|
|
178
|
+
not on sys.path). None when it is missing: the hook then adds nothing.
|
|
179
|
+
"""
|
|
180
|
+
global _RESOURCES
|
|
181
|
+
if _RESOURCES is None:
|
|
182
|
+
_RESOURCES = False
|
|
183
|
+
try:
|
|
184
|
+
if __package__:
|
|
185
|
+
from . import resources as module # type: ignore[attr-defined]
|
|
186
|
+
_RESOURCES = module
|
|
187
|
+
else:
|
|
188
|
+
import importlib.util
|
|
189
|
+
here = Path(os.path.abspath(__file__)).parent
|
|
190
|
+
for name in ("crow_resources.py", "resources.py"):
|
|
191
|
+
path = here / name
|
|
192
|
+
if path.is_file() and not path.is_symlink():
|
|
193
|
+
spec = importlib.util.spec_from_file_location("crow_resources", path)
|
|
194
|
+
module = importlib.util.module_from_spec(spec)
|
|
195
|
+
# Bytecode is cached in __pycache__ beside it (~5 ms less per prompt);
|
|
196
|
+
# the integrations uninstall removes that directory with the scripts.
|
|
197
|
+
spec.loader.exec_module(module)
|
|
198
|
+
_RESOURCES = module
|
|
199
|
+
break
|
|
200
|
+
except Exception: # noqa: BLE001 - a broken probe never breaks the hook
|
|
201
|
+
_RESOURCES = False
|
|
202
|
+
return _RESOURCES or None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_BROADCAST = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def broadcast_module():
|
|
209
|
+
"""crow-broadcast: the package module, or crow_broadcast.py beside this file; None when missing."""
|
|
210
|
+
global _BROADCAST
|
|
211
|
+
if _BROADCAST is None:
|
|
212
|
+
_BROADCAST = False
|
|
213
|
+
try:
|
|
214
|
+
if __package__:
|
|
215
|
+
from . import broadcast as module # type: ignore[attr-defined]
|
|
216
|
+
_BROADCAST = module
|
|
217
|
+
else:
|
|
218
|
+
import importlib.util
|
|
219
|
+
here = Path(os.path.abspath(__file__)).parent
|
|
220
|
+
for name in ("crow_broadcast.py", "broadcast.py"):
|
|
221
|
+
path = here / name
|
|
222
|
+
if path.is_file() and not path.is_symlink():
|
|
223
|
+
spec = importlib.util.spec_from_file_location("crow_broadcast", path)
|
|
224
|
+
module = importlib.util.module_from_spec(spec)
|
|
225
|
+
spec.loader.exec_module(module)
|
|
226
|
+
_BROADCAST = module
|
|
227
|
+
break
|
|
228
|
+
except Exception: # noqa: BLE001 - a broken module never breaks the hook
|
|
229
|
+
_BROADCAST = False
|
|
230
|
+
return _BROADCAST or None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def resource_hint(home):
|
|
234
|
+
"""The one-line memory hint, or None (no pressure, no probe, or any failure)."""
|
|
235
|
+
module = resources_module()
|
|
236
|
+
if module is None:
|
|
237
|
+
return None
|
|
238
|
+
try:
|
|
239
|
+
return module.hint(module.probe(home, processes=False, use_cache=True))
|
|
240
|
+
except Exception: # noqa: BLE001
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
155
244
|
# --- event bus ----------------------------------------------------------------------------
|
|
156
245
|
|
|
157
246
|
def append_event(home, event):
|
|
@@ -225,8 +314,11 @@ def git_branch(cwd):
|
|
|
225
314
|
return None
|
|
226
315
|
|
|
227
316
|
|
|
228
|
-
def update_session(home, event, extra=None, fallback_cwd=None):
|
|
229
|
-
"""Fold one event into the per-session snapshot (state, counters, last action).
|
|
317
|
+
def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
|
|
318
|
+
"""Fold one event into the per-session snapshot (state, counters, last action).
|
|
319
|
+
|
|
320
|
+
presence: (broadcast module, touched paths, parent pid) to fold the crow-broadcast
|
|
321
|
+
presence in the same locked write; a failure there never loses the event."""
|
|
230
322
|
directory = no_follow_dir(events_dir(home) / "sessions")
|
|
231
323
|
key = session_key(event.get("host"), event.get("session"), event.get("cwd"))
|
|
232
324
|
path = directory / (key + ".json")
|
|
@@ -280,6 +372,12 @@ def update_session(home, event, extra=None, fallback_cwd=None):
|
|
|
280
372
|
snap["updatedAt"] = event.get("ts") or now_iso()
|
|
281
373
|
if extra:
|
|
282
374
|
snap.update(extra)
|
|
375
|
+
if presence:
|
|
376
|
+
module, touched, ppid = presence
|
|
377
|
+
try:
|
|
378
|
+
module.fold(snap, event, touched, ppid=ppid)
|
|
379
|
+
except Exception: # noqa: BLE001 - presence is best effort
|
|
380
|
+
pass
|
|
283
381
|
write_json(path, snap)
|
|
284
382
|
return snap
|
|
285
383
|
finally:
|
|
@@ -300,13 +398,13 @@ def prune_sessions(home, now=None):
|
|
|
300
398
|
pass
|
|
301
399
|
|
|
302
400
|
|
|
303
|
-
def record(home, fields, snapshot=False, fallback_cwd=None):
|
|
401
|
+
def record(home, fields, snapshot=False, fallback_cwd=None, presence=None):
|
|
304
402
|
"""Stamp and append an event; optionally fold it into the session snapshot."""
|
|
305
403
|
event = {"v": 1, "schema": EVENT_SCHEMA, "ts": now_iso()}
|
|
306
404
|
event.update({k: v for k, v in fields.items() if v is not None})
|
|
307
405
|
append_event(home, event)
|
|
308
406
|
if snapshot:
|
|
309
|
-
return event, update_session(home, event, fallback_cwd=fallback_cwd)
|
|
407
|
+
return event, update_session(home, event, fallback_cwd=fallback_cwd, presence=presence)
|
|
310
408
|
return event, None
|
|
311
409
|
|
|
312
410
|
|
|
@@ -328,7 +426,7 @@ def sessions(home, live_only=True, now=None):
|
|
|
328
426
|
continue
|
|
329
427
|
snap["ageSeconds"] = int(age)
|
|
330
428
|
result.append(snap)
|
|
331
|
-
return sorted(result, key=lambda s: s.get("updatedAt") or "", reverse=True)
|
|
429
|
+
return sorted(result, key=lambda s: str(s.get("updatedAt") or ""), reverse=True)
|
|
332
430
|
|
|
333
431
|
|
|
334
432
|
def tail(home, limit=50):
|
|
@@ -384,14 +482,26 @@ def foreign(host, payload, env, home):
|
|
|
384
482
|
return False
|
|
385
483
|
|
|
386
484
|
|
|
485
|
+
def tool_call(payload):
|
|
486
|
+
"""Antigravity sends the tool as {"toolCall": {"name", "args"}}."""
|
|
487
|
+
call = payload.get("toolCall")
|
|
488
|
+
return call if isinstance(call, dict) else {}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def workspace(payload):
|
|
492
|
+
"""Antigravity runs hooks from the hooks.json directory and sends the workspace as workspacePaths."""
|
|
493
|
+
paths = payload.get("workspacePaths")
|
|
494
|
+
return paths[0] if isinstance(paths, list) and paths and isinstance(paths[0], str) else None
|
|
495
|
+
|
|
496
|
+
|
|
387
497
|
def normalise(host, raw, payload):
|
|
388
498
|
kind = EVENTS.get(raw, "other")
|
|
389
|
-
tool = token(first(payload, "tool_name", "toolName", "tool"), 64)
|
|
499
|
+
tool = token(first(payload, "tool_name", "toolName", "tool") or tool_call(payload).get("name"), 64)
|
|
390
500
|
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
391
501
|
event = {
|
|
392
502
|
"host": host, "event": kind, "raw": token(raw, 40),
|
|
393
503
|
"session": token(first(payload, "session_id", "sessionId", "conversation_id", "conversationId", "thread_id"), 128),
|
|
394
|
-
"cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot")),
|
|
504
|
+
"cwd": plain_path(first(payload, "cwd", "workspace_root", "workspaceRoot") or workspace(payload)),
|
|
395
505
|
"model": token(first(payload, "model", "model_name", "modelName"), 80) if not isinstance(payload.get("model"), dict)
|
|
396
506
|
else token(payload["model"].get("id") or payload["model"].get("display_name"), 80),
|
|
397
507
|
"permissionMode": token(first(payload, "permission_mode", "permissionMode", "approval_policy", "approvalMode"), 40),
|
|
@@ -443,11 +553,107 @@ def output_for(host, raw, context=None, system=None, block=None):
|
|
|
443
553
|
return out or None
|
|
444
554
|
|
|
445
555
|
|
|
446
|
-
|
|
556
|
+
TOOL_START_EVENTS = ("PreToolUse", "BeforeTool")
|
|
557
|
+
SHELL_NAMES = frozenset(("Bash", "shell", "exec_command", "local_shell", "run_shell_command", "run_command", "exec", "bash"))
|
|
558
|
+
# Edit and shell tool names of every host (crow_broadcast.EDIT_TOOLS and SHELL_TOOLS; a test keeps
|
|
559
|
+
# them equal): only these tool events load the broadcast module.
|
|
560
|
+
BROADCAST_TOOLS = frozenset((
|
|
561
|
+
"Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch", "write_file", "replace", "code_action", "file_change",
|
|
562
|
+
"propose_code", "edit_notebook", "write_blob", "write_to_file", "replace_file_content", "multi_replace_file_content",
|
|
563
|
+
"edit", "write", "notebook_edit",
|
|
564
|
+
"Bash", "shell", "exec_command", "local_shell", "run_shell_command", "run_command", "exec", "bash"))
|
|
565
|
+
# Hosts whose tool-start hook is synchronous in our install (its output is read): the warning goes
|
|
566
|
+
# out from the same call. Claude Code and Codex record asynchronously and get a separate --check entry.
|
|
567
|
+
SYNC_TOOL_HOSTS = ("gemini", "agy", "devin")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def tool_input_of(payload):
|
|
571
|
+
args = tool_call(payload).get("args")
|
|
572
|
+
if isinstance(args, dict):
|
|
573
|
+
return args
|
|
574
|
+
for key in ("tool_input", "toolInput", "input", "tool_args", "arguments", "args"):
|
|
575
|
+
value = payload.get(key)
|
|
576
|
+
if isinstance(value, dict):
|
|
577
|
+
return value
|
|
578
|
+
if isinstance(value, str) and key in ("arguments", "tool_input") and value[:1] == "{" and len(value) < MAX_STDIN:
|
|
579
|
+
try:
|
|
580
|
+
parsed = json.loads(value)
|
|
581
|
+
except ValueError:
|
|
582
|
+
continue
|
|
583
|
+
if isinstance(parsed, dict):
|
|
584
|
+
return parsed
|
|
585
|
+
return {}
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# Where a warning can reach the model before/after a tool, per host (checked on the installed
|
|
589
|
+
# versions, see docs/broadcast.md): Claude Code and Codex read additionalContext on PreToolUse;
|
|
590
|
+
# Gemini CLI and Devin only on AfterTool/PostToolUse (their pre-tool hook can only block);
|
|
591
|
+
# Antigravity's PreToolUse reads only a decision (the next PreInvocation digest carries the warning).
|
|
592
|
+
PRE_CONTEXT_HOSTS = ("claude", "codex")
|
|
593
|
+
POST_CONTEXT_HOSTS = ("gemini", "devin")
|
|
594
|
+
POST_TOOL_EVENTS = ("PostToolUse", "AfterTool")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def tool_output(host, raw, text, block=False):
|
|
598
|
+
"""Tool-hook warning (model-visible where the host reads it) or, with block, a refusal."""
|
|
599
|
+
if block:
|
|
600
|
+
if host == "claude":
|
|
601
|
+
return {"hookSpecificOutput": {"hookEventName": raw, "permissionDecision": "deny", "permissionDecisionReason": text,
|
|
602
|
+
"additionalContext": text}}
|
|
603
|
+
if host in ("agy", "gemini"):
|
|
604
|
+
return {"decision": "deny", "reason": text}
|
|
605
|
+
return {"decision": "block", "reason": text}
|
|
606
|
+
if raw in TOOL_START_EVENTS and host not in PRE_CONTEXT_HOSTS:
|
|
607
|
+
return None
|
|
608
|
+
return {"hookSpecificOutput": {"hookEventName": raw, "additionalContext": text}, "systemMessage": text.split("\n", 1)[0]}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def broadcast_check(host, raw, home, module, me, touched, tree):
|
|
612
|
+
"""Run the conflict check; returns the host output or None. Never raises."""
|
|
613
|
+
try:
|
|
614
|
+
cfg = module.config(home)
|
|
615
|
+
pre = raw in TOOL_START_EVENTS
|
|
616
|
+
block = pre and bool(cfg["block_on_conflict"])
|
|
617
|
+
if pre and not block and host not in PRE_CONTEXT_HOSTS:
|
|
618
|
+
return None # nothing this host would show before the tool; the post-tool or prompt hook warns
|
|
619
|
+
text, conflict = module.check(home, (me or {}).get("key"), me, touched, tree, cfg=cfg)
|
|
620
|
+
if not text:
|
|
621
|
+
return None
|
|
622
|
+
return tool_output(host, raw, text, block=block and conflict)
|
|
623
|
+
except Exception: # noqa: BLE001
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def check_only(host, raw, payload, home):
|
|
628
|
+
"""--check: read-only conflict check before an edit/shell tool (no event, no snapshot write)."""
|
|
629
|
+
tool = first(payload, "tool_name", "toolName", "tool") or tool_call(payload).get("name")
|
|
630
|
+
shell_input = tool_input_of(payload)
|
|
631
|
+
command = shell_input.get("command") or shell_input.get("cmd") or shell_input.get("CommandLine") or shell_input.get("commandLine")
|
|
632
|
+
if tool in SHELL_NAMES and "git" not in json.dumps(command or ""):
|
|
633
|
+
return None # a shell command without git cannot switch the clone's branch: nothing to load
|
|
634
|
+
module = broadcast_module()
|
|
635
|
+
if module is None or raw not in TOOL_START_EVENTS:
|
|
636
|
+
return None
|
|
637
|
+
event = normalise(host, raw, payload)
|
|
638
|
+
cwd = event.get("cwd") or process_cwd()
|
|
639
|
+
tool_input = tool_input_of(payload)
|
|
640
|
+
touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
|
|
641
|
+
tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
|
|
642
|
+
if not touched and not tree:
|
|
643
|
+
return None
|
|
644
|
+
key = session_key(host, event.get("session"), event.get("cwd"))
|
|
645
|
+
me = read_json(events_dir(home) / "sessions" / (key + ".json")) or {}
|
|
646
|
+
me = dict(me, key=key, cwd=me.get("cwd") or cwd)
|
|
647
|
+
return broadcast_check(host, raw, home, module, me, touched, tree)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def handle(host, raw, payload, home, env=None, check=False):
|
|
447
651
|
"""Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
|
|
448
652
|
env = os.environ if env is None else env
|
|
449
653
|
if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
|
|
450
654
|
return None
|
|
655
|
+
if check:
|
|
656
|
+
return check_only(host, raw, payload, home)
|
|
451
657
|
event = normalise(host, raw, payload)
|
|
452
658
|
current = read_mode(home)
|
|
453
659
|
if raw in PROMPT_EVENTS:
|
|
@@ -460,7 +666,49 @@ def handle(host, raw, payload, home, env=None):
|
|
|
460
666
|
current = write_mode(home, wanted, host)
|
|
461
667
|
record(home, dict(event, event="mode_command", mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd())
|
|
462
668
|
return output_for(host, raw, block=indicator(current["mode"]) + " (crow-skills; vale para todos os CLIs a partir do próximo prompt)")
|
|
463
|
-
|
|
669
|
+
# The broadcast module is loaded only where it has work: prompts and session start (repo,
|
|
670
|
+
# pid, digest) and tool events of edit/shell tools (paths, checks). Other events keep the
|
|
671
|
+
# 0.9.0 cost; the snapshot's updatedAt is the heartbeat.
|
|
672
|
+
wanted = raw in PROMPT_EVENTS or raw == "SessionStart" or \
|
|
673
|
+
(event["event"] in ("tool_start", "tool_end") and event.get("tool") in BROADCAST_TOOLS)
|
|
674
|
+
module, presence, touched, tree, checked = broadcast_module() if wanted else None, None, [], [], []
|
|
675
|
+
if module is not None:
|
|
676
|
+
try:
|
|
677
|
+
cfg = module.config(home)
|
|
678
|
+
if cfg["enabled"]:
|
|
679
|
+
if event["event"] == "tool_start":
|
|
680
|
+
tool_input = tool_input_of(payload)
|
|
681
|
+
cwd = event.get("cwd") or process_cwd()
|
|
682
|
+
touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
|
|
683
|
+
checked = list(touched)
|
|
684
|
+
tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
|
|
685
|
+
if touched and cfg["block_on_conflict"]:
|
|
686
|
+
# An edit the conflict check is about to refuse is not published as this
|
|
687
|
+
# session's: otherwise the session that edited first would be locked out too.
|
|
688
|
+
key = session_key(host, event.get("session"), event.get("cwd"))
|
|
689
|
+
mine = read_json(events_dir(home) / "sessions" / (key + ".json")) or {}
|
|
690
|
+
mine = dict(mine, key=key, cwd=mine.get("cwd") or cwd)
|
|
691
|
+
if module.check(home, key, mine, touched, (), cfg=cfg)[1]:
|
|
692
|
+
touched = []
|
|
693
|
+
presence = (module, touched, os.getppid())
|
|
694
|
+
else:
|
|
695
|
+
module = None
|
|
696
|
+
except Exception: # noqa: BLE001 - the broadcast never breaks the event
|
|
697
|
+
module, presence = None, None
|
|
698
|
+
stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
|
|
699
|
+
if module is not None and host in SYNC_TOOL_HOSTS:
|
|
700
|
+
if raw in TOOL_START_EVENTS and (touched or tree or checked):
|
|
701
|
+
return broadcast_check(host, raw, home, module, snap, checked or touched, tree)
|
|
702
|
+
if raw in POST_TOOL_EVENTS and host in POST_CONTEXT_HOSTS:
|
|
703
|
+
try:
|
|
704
|
+
tool_input = tool_input_of(payload)
|
|
705
|
+
cwd = event.get("cwd") or process_cwd()
|
|
706
|
+
after = module.edit_paths(host, event.get("tool"), tool_input, cwd)
|
|
707
|
+
tree_after = module.tree_command(host, event.get("tool"), tool_input, cwd)
|
|
708
|
+
except Exception: # noqa: BLE001
|
|
709
|
+
after, tree_after = [], []
|
|
710
|
+
if after or tree_after:
|
|
711
|
+
return broadcast_check(host, raw, home, module, snap, after, tree_after)
|
|
464
712
|
if raw not in PROMPT_EVENTS and raw != "SessionStart":
|
|
465
713
|
return None
|
|
466
714
|
system = None
|
|
@@ -472,7 +720,18 @@ def handle(host, raw, payload, home, env=None):
|
|
|
472
720
|
update_session(home, dict(stamped, event="other"), {"modeSeen": stamp, "mode": current["mode"]})
|
|
473
721
|
if raw == "SessionStart":
|
|
474
722
|
return output_for(host, raw, system=system) if system else None
|
|
475
|
-
|
|
723
|
+
context = context_line(current["mode"])
|
|
724
|
+
memory = resource_hint(home)
|
|
725
|
+
if memory:
|
|
726
|
+
context += "\n" + memory
|
|
727
|
+
if module is not None and snap is not None:
|
|
728
|
+
try:
|
|
729
|
+
others = module.digest(home, snap.get("key"), me=snap)
|
|
730
|
+
except Exception: # noqa: BLE001
|
|
731
|
+
others = None
|
|
732
|
+
if others:
|
|
733
|
+
context += "\n" + others
|
|
734
|
+
return output_for(host, raw, context=context, system=system)
|
|
476
735
|
|
|
477
736
|
|
|
478
737
|
def update_metrics(home, host, payload):
|
|
@@ -515,7 +774,36 @@ def update_metrics(home, host, payload):
|
|
|
515
774
|
|
|
516
775
|
# --- entry point --------------------------------------------------------------------------
|
|
517
776
|
|
|
777
|
+
def fast_hook_args(argv):
|
|
778
|
+
"""The hook's own argv parsed without argparse (~5 ms per call); None when it is anything else."""
|
|
779
|
+
if not argv or argv[0] != "hook":
|
|
780
|
+
return None
|
|
781
|
+
values, flags, i = {}, set(), 1
|
|
782
|
+
while i < len(argv):
|
|
783
|
+
name = argv[i]
|
|
784
|
+
if name in ("--host", "--event", "--home") and i + 1 < len(argv):
|
|
785
|
+
values[name[2:]] = argv[i + 1]
|
|
786
|
+
i += 2
|
|
787
|
+
elif name == "--check":
|
|
788
|
+
flags.add("check")
|
|
789
|
+
i += 1
|
|
790
|
+
else:
|
|
791
|
+
return None
|
|
792
|
+
if "host" not in values or "event" not in values:
|
|
793
|
+
return None
|
|
794
|
+
import types
|
|
795
|
+
return types.SimpleNamespace(command="hook", host=values["host"], event=values["event"], home=values.get("home"),
|
|
796
|
+
check="check" in flags)
|
|
797
|
+
|
|
798
|
+
|
|
518
799
|
def main(argv=None):
|
|
800
|
+
args = fast_hook_args(list(sys.argv[1:] if argv is None else argv))
|
|
801
|
+
if args is None:
|
|
802
|
+
args = parse_args(argv)
|
|
803
|
+
return run_main(args)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def parse_args(argv):
|
|
519
807
|
import argparse
|
|
520
808
|
p = argparse.ArgumentParser(prog="crow_hook", description="Hook e modo do crow-skills.")
|
|
521
809
|
sub = p.add_subparsers(dest="command", required=True)
|
|
@@ -523,12 +811,16 @@ def main(argv=None):
|
|
|
523
811
|
hook.add_argument("--host", required=True)
|
|
524
812
|
hook.add_argument("--event", required=True)
|
|
525
813
|
hook.add_argument("--home")
|
|
814
|
+
hook.add_argument("--check", action="store_true")
|
|
526
815
|
mode = sub.add_parser("mode")
|
|
527
816
|
mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
|
|
528
817
|
mode.add_argument("--home")
|
|
529
818
|
mode.add_argument("--by")
|
|
530
819
|
mode.add_argument("--json", action="store_true")
|
|
531
|
-
|
|
820
|
+
return p.parse_args(argv)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def run_main(args):
|
|
532
824
|
home = Path(args.home).expanduser() if args.home else Path.home()
|
|
533
825
|
if args.command == "mode":
|
|
534
826
|
current = read_mode(home)
|
|
@@ -546,11 +838,18 @@ def main(argv=None):
|
|
|
546
838
|
try:
|
|
547
839
|
raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
|
|
548
840
|
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)
|
|
841
|
+
result = handle(args.host, args.event, payload, home, check=args.check)
|
|
552
842
|
if result:
|
|
553
843
|
sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
844
|
+
sys.stdout.flush()
|
|
845
|
+
if args.event == "SessionStart" and not args.check:
|
|
846
|
+
prune_sessions(home)
|
|
847
|
+
module = broadcast_module()
|
|
848
|
+
if module is not None:
|
|
849
|
+
try:
|
|
850
|
+
module.prune_claims(home)
|
|
851
|
+
except Exception: # noqa: BLE001 - pruning is housekeeping; the output already went out
|
|
852
|
+
pass
|
|
554
853
|
except Exception: # noqa: BLE001 - a hook must never break the agent
|
|
555
854
|
return 0
|
|
556
855
|
return 0
|
|
@@ -55,6 +55,8 @@ OWNERS = {"claude-statusline": ("claude",), "claude-hooks": ("claude",), "claude
|
|
|
55
55
|
"gemini-hooks": ("gemini",), "gemini-command": ("gemini",), "agy-hooks": ("gemini",), "agy-statusline": ("gemini",),
|
|
56
56
|
"devin-hooks": ("devin",), "tmux-status": ("claude", "codex", "gemini", "devin")}
|
|
57
57
|
HOOK_SCRIPT = "crow_hook.py"
|
|
58
|
+
RESOURCES_SCRIPT = "crow_resources.py"
|
|
59
|
+
BROADCAST_SCRIPT = "crow_broadcast.py"
|
|
58
60
|
COMMAND_HEADER = "Gerenciado por crow-skills (comando /crow). Não edite; use crow-skills integrations."
|
|
59
61
|
BEGIN = "# >>> crow-skills integrations (gerenciado; use crow-skills integrations uninstall) >>>"
|
|
60
62
|
END = "# <<< crow-skills integrations <<<"
|
|
@@ -117,6 +119,45 @@ def hook_source():
|
|
|
117
119
|
return (Path(__file__).resolve().parent / "hook.py").read_bytes()
|
|
118
120
|
|
|
119
121
|
|
|
122
|
+
def resources_script_path(store):
|
|
123
|
+
return root(store) / RESOURCES_SCRIPT
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resources_source():
|
|
127
|
+
return (Path(__file__).resolve().parent / "resources.py").read_bytes()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def broadcast_script_path(store):
|
|
131
|
+
return root(store) / BROADCAST_SCRIPT
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def broadcast_source():
|
|
135
|
+
return (Path(__file__).resolve().parent / "broadcast.py").read_bytes()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def remove_bytecode(store):
|
|
139
|
+
"""__pycache__ beside the copied scripts (written when the status line imports crow_hook):
|
|
140
|
+
removed with them, only when it holds nothing but their bytecode."""
|
|
141
|
+
cache = root(store) / "__pycache__"
|
|
142
|
+
if cache.is_symlink() or not cache.is_dir():
|
|
143
|
+
return
|
|
144
|
+
names = ("crow_progress.", "crow_hook.", "crow_resources.", "crow_broadcast.")
|
|
145
|
+
entries = list(cache.iterdir())
|
|
146
|
+
if all(e.is_file() and not e.is_symlink() and e.name.startswith(names) and e.suffix == ".pyc" for e in entries):
|
|
147
|
+
try:
|
|
148
|
+
for entry in entries:
|
|
149
|
+
entry.unlink()
|
|
150
|
+
cache.rmdir()
|
|
151
|
+
except OSError:
|
|
152
|
+
pass # a hook wrote bytecode meanwhile: the directory stays, nothing else is touched
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def scripts(store):
|
|
156
|
+
"""(installed path, source bytes) of every script the hooks and status lines run."""
|
|
157
|
+
return ((script_path(store), script_source()), (hook_script_path(store), hook_source()),
|
|
158
|
+
(resources_script_path(store), resources_source()), (broadcast_script_path(store), broadcast_source()))
|
|
159
|
+
|
|
160
|
+
|
|
120
161
|
def quoted_ok(*paths):
|
|
121
162
|
return all(not re.search(r"[\"'\\$`\n]", str(p)) for p in paths)
|
|
122
163
|
|
|
@@ -333,37 +374,57 @@ def codex_hooks_feature(home):
|
|
|
333
374
|
return " Habilite também [features] hooks = true em ~/.codex/config.toml (não alterado pelo crow-skills)."
|
|
334
375
|
|
|
335
376
|
|
|
336
|
-
def hook_command(store, home, python, host, event):
|
|
377
|
+
def hook_command(store, home, python, host, event, check=False):
|
|
337
378
|
return (shlex.quote(str(python)) + " -I -S " + shlex.quote(str(hook_script_path(store))) + " hook --host " + host +
|
|
338
|
-
" --event " + event + " --home " + shlex.quote(str(home)))
|
|
379
|
+
" --event " + event + (" --check" if check else "") + " --home " + shlex.quote(str(home)))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# crow-broadcast: Claude Code and Codex record tool events asynchronously (their output is
|
|
383
|
+
# ignored), so a second, synchronous and read-only PreToolUse entry, limited by matcher to the
|
|
384
|
+
# edit and shell tools, injects the conflict warning (or blocks, with broadcast.block_on_conflict).
|
|
385
|
+
CHECK_MATCHERS = {"claude": "Edit|Write|MultiEdit|NotebookEdit|Bash", "codex": "apply_patch|Bash"}
|
|
386
|
+
# Antigravity wraps tool events in {"matcher", "hooks"} groups; the other events are flat lists.
|
|
387
|
+
AGY_GROUPED = ("PreToolUse", "PostToolUse")
|
|
339
388
|
|
|
340
389
|
|
|
341
390
|
def hook_entries(store, home, python, host):
|
|
342
|
-
"""{event: [
|
|
391
|
+
"""{event: [item]} in the host's own schema: groups ({"hooks": [...], "matcher"?}) for the
|
|
392
|
+
"groups" layout, the agy block's own items (flat handlers or matcher groups) for agy."""
|
|
343
393
|
command = lambda event: hook_command(store, home, python, host, event) # noqa: E731
|
|
344
|
-
if host
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
394
|
+
if host in ("claude", "codex"):
|
|
395
|
+
events = CLAUDE_EVENTS if host == "claude" else CODEX_EVENTS
|
|
396
|
+
result = {e: [{"hooks": [dict({"type": "command", "command": command(e), "timeout": 5}, **({"async": True} if a else {}))]}]
|
|
397
|
+
for e, a in events}
|
|
398
|
+
result["PreToolUse"].append({"matcher": CHECK_MATCHERS[host], "hooks": [
|
|
399
|
+
{"type": "command", "command": hook_command(store, home, python, host, "PreToolUse", check=True), "timeout": 5}]})
|
|
400
|
+
return result
|
|
350
401
|
if host == "gemini":
|
|
351
|
-
return {e: [{"type": "command", "name": "crow-skills-" + e, "command": command(e), "timeout": 3000}]
|
|
402
|
+
return {e: [{"hooks": [{"type": "command", "name": "crow-skills-" + e, "command": command(e), "timeout": 3000}]}]
|
|
403
|
+
for e in GEMINI_EVENTS}
|
|
352
404
|
if host == "agy":
|
|
353
|
-
return {e: [{"type": "command", "command": command(e), "timeout": 3}]
|
|
354
|
-
|
|
405
|
+
return {e: ([{"matcher": "", "hooks": [{"type": "command", "command": command(e), "timeout": 3}]}] if e in AGY_GROUPED
|
|
406
|
+
else [{"type": "command", "command": command(e), "timeout": 3}]) for e in AGY_EVENTS}
|
|
407
|
+
return {e: [{"hooks": [{"type": "command", "command": command(e)}]}] for e in DEVIN_EVENTS}
|
|
355
408
|
|
|
356
409
|
|
|
357
410
|
def is_ours(entry, store):
|
|
358
411
|
return isinstance(entry, dict) and isinstance(entry.get("command"), str) and str(hook_script_path(store)) in entry["command"]
|
|
359
412
|
|
|
360
413
|
|
|
414
|
+
def ours_item(item, store):
|
|
415
|
+
"""An agy block item of ours: a handler, or a matcher group holding only our handlers."""
|
|
416
|
+
if is_ours(item, store):
|
|
417
|
+
return True
|
|
418
|
+
hooks = item.get("hooks") if isinstance(item, dict) else None
|
|
419
|
+
return isinstance(hooks, list) and bool(hooks) and all(is_ours(h, store) for h in hooks)
|
|
420
|
+
|
|
421
|
+
|
|
361
422
|
def strip_hooks(data, store, layout):
|
|
362
423
|
"""A copy of the settings without this CLI's hook entries (user entries keep their order)."""
|
|
363
424
|
result = deepcopy(data)
|
|
364
425
|
if layout == "agy":
|
|
365
426
|
block = result.get(AGY_KEY)
|
|
366
|
-
if isinstance(block, dict) and all(isinstance(v, list) and all(
|
|
427
|
+
if isinstance(block, dict) and all(isinstance(v, list) and all(ours_item(e, store) for e in v) for v in block.values()):
|
|
367
428
|
result.pop(AGY_KEY)
|
|
368
429
|
return result
|
|
369
430
|
hooks = result.get("hooks")
|
|
@@ -392,8 +453,9 @@ def strip_hooks(data, store, layout):
|
|
|
392
453
|
|
|
393
454
|
|
|
394
455
|
def refresh_in_place(data, entries, store, layout):
|
|
395
|
-
"""Replace our existing entries where they are (same events,
|
|
396
|
-
never moves the user's hooks or ours;
|
|
456
|
+
"""Replace our existing entries where they are (same events, same number of groups, each in a
|
|
457
|
+
group of its own with the same matcher), so a re-sync never moves the user's hooks or ours;
|
|
458
|
+
None when the layout does not match the desired one (the caller strips and appends)."""
|
|
397
459
|
result = deepcopy(data)
|
|
398
460
|
if layout == "agy":
|
|
399
461
|
if not isinstance(result.get(AGY_KEY), dict):
|
|
@@ -409,10 +471,14 @@ def refresh_in_place(data, entries, store, layout):
|
|
|
409
471
|
for hi, item in enumerate(group.get("hooks", []) if isinstance(group, dict) else []):
|
|
410
472
|
if is_ours(item, store):
|
|
411
473
|
found.setdefault(event, []).append((gi, hi))
|
|
412
|
-
if set(found) != set(entries) or any(len(v) !=
|
|
474
|
+
if set(found) != set(entries) or any(len(v) != len(entries[e]) for e, v in found.items()):
|
|
413
475
|
return None
|
|
414
|
-
for event,
|
|
415
|
-
|
|
476
|
+
for event, places in found.items():
|
|
477
|
+
for (gi, hi), wanted in zip(places, entries[event]):
|
|
478
|
+
group = hooks[event][gi]
|
|
479
|
+
if len(group.get("hooks", [])) != 1 or group.get("matcher") != wanted.get("matcher"):
|
|
480
|
+
return None
|
|
481
|
+
group["hooks"][hi] = wanted["hooks"][0]
|
|
416
482
|
return result
|
|
417
483
|
|
|
418
484
|
|
|
@@ -422,8 +488,8 @@ def add_hooks(data, entries, layout):
|
|
|
422
488
|
result[AGY_KEY] = entries
|
|
423
489
|
return result
|
|
424
490
|
hooks = result.setdefault("hooks", {})
|
|
425
|
-
for event,
|
|
426
|
-
hooks.setdefault(event, []).
|
|
491
|
+
for event, groups in entries.items():
|
|
492
|
+
hooks.setdefault(event, []).extend(deepcopy(groups)) # appended: the user's hooks keep their index (Codex trust)
|
|
427
493
|
return result
|
|
428
494
|
|
|
429
495
|
|
|
@@ -825,7 +891,7 @@ def sync(store, home, wanted, dry_run=False, python=None):
|
|
|
825
891
|
with (nullcontext() if dry_run else store.lock()):
|
|
826
892
|
if not dry_run and any(set(OWNERS[n]) & set(wanted) for n in NAMES):
|
|
827
893
|
private_dir(root(store))
|
|
828
|
-
for script, source in (
|
|
894
|
+
for script, source in scripts(store):
|
|
829
895
|
if not script.exists() or sha(script.read_bytes()) != sha(source):
|
|
830
896
|
write_atomic(checked(script), source, 0o644)
|
|
831
897
|
# Removals first and in reverse order, then installs in order: two integrations that
|
|
@@ -859,9 +925,11 @@ def sync(store, home, wanted, dry_run=False, python=None):
|
|
|
859
925
|
if after != state:
|
|
860
926
|
atomic_json(state_path(store), after)
|
|
861
927
|
if not after["integrations"]:
|
|
862
|
-
for script in (script_path(store), hook_script_path(store))
|
|
928
|
+
for script in (script_path(store), hook_script_path(store), resources_script_path(store),
|
|
929
|
+
broadcast_script_path(store)):
|
|
863
930
|
if script.exists():
|
|
864
931
|
script.unlink()
|
|
932
|
+
remove_bytecode(store)
|
|
865
933
|
for key in ("changes", "skipped"):
|
|
866
934
|
report[key].sort(key=lambda c: NAMES.index(c["integration"]) if c["integration"] in NAMES else 99)
|
|
867
935
|
report["dry_run"] = dry_run
|