@andespindola/crow-skills 0.7.0 → 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.
Files changed (50) hide show
  1. package/README.md +6 -5
  2. package/crow_skills/__init__.py +1 -1
  3. package/crow_skills/autoupdate.py +398 -0
  4. package/crow_skills/cli.py +89 -3
  5. package/crow_skills/events.py +111 -0
  6. package/crow_skills/guide.py +7 -1
  7. package/crow_skills/hook.py +560 -0
  8. package/crow_skills/integrations.py +427 -63
  9. package/crow_skills/progress.py +106 -14
  10. package/crow_skills/source.py +40 -0
  11. package/crow_skills/storage.py +5 -4
  12. package/docs/cli.md +96 -17
  13. package/package.json +1 -1
  14. package/release.json +63 -48
  15. package/skills/crow-accessibility/SKILL.md +5 -5
  16. package/skills/crow-agent-security/SKILL.md +5 -5
  17. package/skills/crow-api-test/SKILL.md +5 -5
  18. package/skills/crow-architecture/SKILL.md +5 -5
  19. package/skills/crow-backend/SKILL.md +5 -5
  20. package/skills/crow-behaviour/SKILL.md +3 -3
  21. package/skills/crow-behaviour/references/progress-ledger.md +17 -3
  22. package/skills/crow-behaviour/scripts/crow_ledger.py +45 -2
  23. package/skills/crow-browser/SKILL.md +5 -5
  24. package/skills/crow-debug/SKILL.md +5 -5
  25. package/skills/crow-design/SKILL.md +5 -5
  26. package/skills/crow-discovery/SKILL.md +5 -5
  27. package/skills/crow-documents/SKILL.md +5 -5
  28. package/skills/crow-domain/SKILL.md +5 -5
  29. package/skills/crow-frontend/SKILL.md +5 -5
  30. package/skills/crow-growth/SKILL.md +5 -5
  31. package/skills/crow-infra/SKILL.md +5 -5
  32. package/skills/crow-interface/SKILL.md +2 -2
  33. package/skills/crow-media/SKILL.md +5 -5
  34. package/skills/crow-native-mobile/SKILL.md +5 -5
  35. package/skills/crow-observe/SKILL.md +6 -6
  36. package/skills/crow-performance/SKILL.md +5 -5
  37. package/skills/crow-platform/SKILL.md +5 -5
  38. package/skills/crow-postgres/SKILL.md +5 -5
  39. package/skills/crow-product-leadership/SKILL.md +5 -5
  40. package/skills/crow-qa/SKILL.md +5 -5
  41. package/skills/crow-release/SKILL.md +5 -5
  42. package/skills/crow-reverse/SKILL.md +5 -5
  43. package/skills/crow-secrets/SKILL.md +5 -5
  44. package/skills/crow-security-review/SKILL.md +5 -5
  45. package/skills/crow-security-scan/SKILL.md +5 -5
  46. package/skills/crow-security-test/SKILL.md +5 -5
  47. package/skills/crow-seo/SKILL.md +5 -5
  48. package/skills/crow-skill-author/SKILL.md +5 -5
  49. package/skills/crow-spec/SKILL.md +5 -5
  50. package/skills/crow-threat-model/SKILL.md +5 -5
@@ -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())