@andespindola/crow-skills 0.7.1 → 0.9.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 (35) 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 +99 -3
  5. package/crow_skills/events.py +133 -0
  6. package/crow_skills/guide.py +7 -1
  7. package/crow_skills/hook.py +616 -0
  8. package/crow_skills/integrations.py +460 -63
  9. package/crow_skills/progress.py +157 -15
  10. package/crow_skills/resources.py +713 -0
  11. package/crow_skills/source.py +40 -0
  12. package/crow_skills/storage.py +5 -4
  13. package/docs/cli.md +144 -17
  14. package/package.json +1 -1
  15. package/release.json +56 -31
  16. package/skills/crow-accessibility/SKILL.md +2 -0
  17. package/skills/crow-api-test/SKILL.md +2 -0
  18. package/skills/crow-backend/SKILL.md +2 -0
  19. package/skills/crow-behaviour/SKILL.md +4 -3
  20. package/skills/crow-behaviour/references/orchestration.md +4 -0
  21. package/skills/crow-behaviour/references/progress-ledger.md +20 -3
  22. package/skills/crow-behaviour/references/resources.md +44 -0
  23. package/skills/crow-behaviour/scripts/crow_ledger.py +120 -4
  24. package/skills/crow-browser/SKILL.md +2 -0
  25. package/skills/crow-debug/SKILL.md +2 -0
  26. package/skills/crow-frontend/SKILL.md +2 -0
  27. package/skills/crow-infra/SKILL.md +2 -0
  28. package/skills/crow-interface/SKILL.md +2 -0
  29. package/skills/crow-media/SKILL.md +2 -0
  30. package/skills/crow-native-mobile/SKILL.md +2 -0
  31. package/skills/crow-performance/SKILL.md +2 -0
  32. package/skills/crow-qa/SKILL.md +2 -0
  33. package/skills/crow-release/SKILL.md +2 -0
  34. package/skills/crow-reverse/SKILL.md +2 -0
  35. package/skills/crow-security-scan/SKILL.md +2 -0
@@ -12,10 +12,19 @@ checked on the host versions listed in docs/cli.md:
12
12
  when the user has not set a status line.
13
13
  - tmux-status: a segment appended to ``status-right`` from a sourced snippet,
14
14
  for every agent, only when tmux is installed and already configured.
15
-
16
- Gemini CLI (footer takes built-in items only), Antigravity (no status/footer
17
- setting) and Devin CLI (no status line) have no surface that can display an
18
- external value; they get the tmux segment and ``crow-skills progress``.
15
+ - agy-statusline: ``statusLine`` in ~/.gemini/antigravity-cli/settings.json,
16
+ stacked under Antigravity's default line (``stack_with_default``).
17
+ - *-hooks: the crow mode hook (crow_hook.py) in each CLI's own hook config:
18
+ claude (~/.claude/settings.json), codex (~/.codex/hooks.json), Gemini CLI
19
+ (~/.gemini/settings.json), agy (~/.gemini/config/hooks.json) and Devin
20
+ (~/.config/devin/config.json). Entries are appended after the user's own and
21
+ identified by the script path; nothing else in those files changes.
22
+ - claude-command / gemini-command: a ``/crow on|off|status`` slash command in
23
+ a file of its own (~/.claude/commands/crow.md, ~/.gemini/commands/crow.toml).
24
+
25
+ Gemini CLI (footer takes built-in items only), Codex (built-in items only)
26
+ and Devin CLI (no status line) show the mode as a one-line systemMessage
27
+ from the hook when it changes; they also get the tmux segment.
19
28
 
20
29
  Ownership: one key (claude), one marked block (codex, tmux.conf) and two whole
21
30
  files under ~/.config/crow-skills and the CLI state. Every edited file is
@@ -39,19 +48,28 @@ from .guide import protect, read_regular, sha, tomllib, write_atomic
39
48
  from .source import CrowError
40
49
  from .storage import atomic_json, checked, private_dir, read_json
41
50
 
42
- NAMES = ("claude-statusline", "codex-statusline", "tmux-status")
43
- OWNERS = {"claude-statusline": ("claude",), "codex-statusline": ("codex",),
44
- "tmux-status": ("claude", "codex", "gemini", "devin")}
51
+ NAMES = ("claude-statusline", "claude-hooks", "claude-command", "codex-statusline", "codex-hooks",
52
+ "gemini-hooks", "gemini-command", "agy-hooks", "agy-statusline", "devin-hooks", "tmux-status")
53
+ OWNERS = {"claude-statusline": ("claude",), "claude-hooks": ("claude",), "claude-command": ("claude",),
54
+ "codex-statusline": ("codex",), "codex-hooks": ("codex",),
55
+ "gemini-hooks": ("gemini",), "gemini-command": ("gemini",), "agy-hooks": ("gemini",), "agy-statusline": ("gemini",),
56
+ "devin-hooks": ("devin",), "tmux-status": ("claude", "codex", "gemini", "devin")}
57
+ HOOK_SCRIPT = "crow_hook.py"
58
+ RESOURCES_SCRIPT = "crow_resources.py"
59
+ COMMAND_HEADER = "Gerenciado por crow-skills (comando /crow). Não edite; use crow-skills integrations."
45
60
  BEGIN = "# >>> crow-skills integrations (gerenciado; use crow-skills integrations uninstall) >>>"
46
61
  END = "# <<< crow-skills integrations <<<"
47
62
  CODEX_ITEMS = ["model-with-reasoning", "context-remaining", "current-dir", "task-progress", "used-tokens"]
48
63
  SNIPPET_HEADER = "# Gerenciado por crow-skills (integração tmux). Não edite; use crow-skills integrations."
49
64
  UNSUPPORTED = {
50
- "gemini": ("Agente gemini do instalador = Antigravity (agy): sem status line nem rodapé configurável; no ledger use host agy, "
51
- "nunca gemini (gemini é o Gemini CLI, cujo rodapé ui.footer.items só aceita itens nativos). "
52
- "Use o segmento tmux ou crow-skills progress."),
53
- "devin": "Devin CLI não tem status line nem rodapé configurável; use o segmento tmux ou crow-skills progress.",
65
+ "gemini": ("Gemini CLI: o rodapé (ui.footer.items) só aceita itens nativos; o modo aparece como systemMessage do hook quando muda. "
66
+ "O Antigravity (agy, mesmo agente gemini do instalador) recebe agy-statusline. No ledger, agy é host agy, nunca gemini."),
67
+ "devin": ("Devin CLI não tem status line nem rodapé configurável; o modo aparece como systemMessage do hook quando muda. "
68
+ "Use o segmento tmux ou crow-skills progress para o resto."),
69
+ "codex": ("Codex: o rodapé só aceita itens nativos e não mostra o modo crow; ele aparece como systemMessage do hook no início "
70
+ "da sessão e quando muda."),
54
71
  }
72
+ UNSUPPORTED_ROW = {"gemini": "gemini-cli-statusline", "devin": "devin-statusline", "codex": "codex-mode-indicator"}
55
73
 
56
74
 
57
75
  def tmux_binary():
@@ -92,6 +110,45 @@ def script_source():
92
110
  return (Path(__file__).resolve().parent / "progress.py").read_bytes()
93
111
 
94
112
 
113
+ def hook_script_path(store):
114
+ return root(store) / HOOK_SCRIPT
115
+
116
+
117
+ def hook_source():
118
+ return (Path(__file__).resolve().parent / "hook.py").read_bytes()
119
+
120
+
121
+ def resources_script_path(store):
122
+ return root(store) / RESOURCES_SCRIPT
123
+
124
+
125
+ def resources_source():
126
+ return (Path(__file__).resolve().parent / "resources.py").read_bytes()
127
+
128
+
129
+ def remove_bytecode(store):
130
+ """__pycache__ beside the copied scripts (written when the status line imports crow_hook):
131
+ removed with them, only when it holds nothing but their bytecode."""
132
+ cache = root(store) / "__pycache__"
133
+ if cache.is_symlink() or not cache.is_dir():
134
+ return
135
+ names = ("crow_progress.", "crow_hook.", "crow_resources.")
136
+ entries = list(cache.iterdir())
137
+ if all(e.is_file() and not e.is_symlink() and e.name.startswith(names) and e.suffix == ".pyc" for e in entries):
138
+ try:
139
+ for entry in entries:
140
+ entry.unlink()
141
+ cache.rmdir()
142
+ except OSError:
143
+ pass # a hook wrote bytecode meanwhile: the directory stays, nothing else is touched
144
+
145
+
146
+ def scripts(store):
147
+ """(installed path, source bytes) of every script the hooks and status lines run."""
148
+ return ((script_path(store), script_source()), (hook_script_path(store), hook_source()),
149
+ (resources_script_path(store), resources_source()))
150
+
151
+
95
152
  def quoted_ok(*paths):
96
153
  return all(not re.search(r"[\"'\\$`\n]", str(p)) for p in paths)
97
154
 
@@ -106,17 +163,6 @@ def backup(store, name, data):
106
163
  return path.name
107
164
 
108
165
 
109
- def restore_bytes(store, record, path, old):
110
- """Original bytes from the install backup, when the file still holds exactly what we wrote."""
111
- name = record.get("backup")
112
- if old is None or not name or record.get("written_sha") != sha(old) or "/" in name:
113
- return None
114
- try:
115
- return read_regular(checked(root(store) / "backups" / name))
116
- except (CrowError, OSError):
117
- return None
118
-
119
-
120
166
  def write(path, data, old):
121
167
  mode = (path.lstat().st_mode & 0o777) if old is not None else 0o644
122
168
  if not path.parent.exists():
@@ -124,19 +170,97 @@ def write(path, data, old):
124
170
  write_atomic(path, data, mode)
125
171
 
126
172
 
127
- # --- claude ---------------------------------------------------------------------------------
173
+ # JSON files shared by integrations (claude settings.json holds the status line and the hooks)
174
+ # keep one restore point per file, state["files"][path]: the bytes before crow-skills first
175
+ # wrote it and the sha of what it last wrote. sync() binds these to the state being built.
176
+ _FILES = {}
177
+ _LIVE = {}
128
178
 
129
- def claude_sync(store, home, record, desired, python, dry):
130
- path = protect(home / ".claude" / "settings.json", home)
179
+
180
+ def tracked_write(store, path, data, old, label):
181
+ key = str(path)
182
+ entry = _FILES.get(key)
183
+ if entry is None:
184
+ entry = _FILES[key] = {"backup": backup(store, label, old), "created_file": old is None}
185
+ elif entry.get("written_sha") != sha(old or b""):
186
+ entry["backup"] = None # edited outside crow-skills since: from now on only our keys are removed
187
+ write(path, data, old)
188
+ entry["written_sha"] = sha(data)
189
+
190
+
191
+ def release(store, name, path, current, label, remaining):
192
+ """Write the file without name's content. The last crow-skills user of an untouched file
193
+ gets the original bytes back (or the file removed, if crow-skills created it)."""
194
+ key = str(path)
195
+ entry = _FILES.get(key)
196
+ others = [n for n, r in _LIVE.items() if n != name and isinstance(r, dict) and r.get("path") == key]
197
+ backup(store, label, current)
198
+ if others:
199
+ data = dump(remaining)
200
+ write(path, data, current)
201
+ if entry is not None:
202
+ if entry.get("written_sha") != sha(current or b""):
203
+ entry["backup"] = None
204
+ entry["written_sha"] = sha(data)
205
+ return
206
+ _FILES.pop(key, None)
207
+ untouched = entry is not None and entry.get("written_sha") == sha(current or b"")
208
+ if untouched and entry.get("created_file"):
209
+ path.unlink()
210
+ return
211
+ if untouched and entry.get("backup") and "/" not in entry["backup"]:
212
+ try:
213
+ original = read_regular(checked(root(store) / "backups" / entry["backup"]))
214
+ except (CrowError, OSError):
215
+ original = None
216
+ if original is not None:
217
+ write(path, original, current)
218
+ return
219
+ if entry is not None and entry.get("created_file") and not remaining:
220
+ path.unlink()
221
+ return
222
+ write(path, dump(remaining), current)
223
+
224
+
225
+ # --- JSON status lines (claude, agy) --------------------------------------------------------
226
+
227
+ STATUSLINES = {
228
+ "claude-statusline": {"path": (".claude", "settings.json"), "chain": "claude-statusline-chain.json",
229
+ "backup": "claude-settings", "label": "~/.claude/settings.json", "args": "",
230
+ "defaults": {"padding": 0}, "requires": None},
231
+ "agy-statusline": {"path": (".gemini", "antigravity-cli", "settings.json"), "chain": "agy-statusline-chain.json",
232
+ "backup": "agy-settings", "label": "~/.gemini/antigravity-cli/settings.json", "args": " --host agy",
233
+ "defaults": {"stack_with_default": True}, "requires": (".gemini", "antigravity-cli")},
234
+ }
235
+
236
+
237
+ def read_json_object(path, label):
238
+ """(bytes, dict) of a JSON settings file, or (bytes, None, reason) when it cannot be edited safely."""
131
239
  old = read_regular(path)
132
240
  try:
133
- settings = json.loads(old.decode("utf-8")) if old is not None and old.strip() else {}
241
+ data = json.loads(old.decode("utf-8")) if old is not None and old.strip() else {}
134
242
  except (UnicodeDecodeError, ValueError):
135
- return None, "skip", "~/.claude/settings.json não é JSON válido; nada alterado."
136
- if not isinstance(settings, dict):
137
- return None, "skip", "~/.claude/settings.json não é um objeto JSON; nada alterado."
243
+ return old, None, label + " não é JSON válido; nada alterado."
244
+ if not isinstance(data, dict):
245
+ return old, None, label + " não é um objeto JSON; nada alterado."
246
+ return old, data, None
247
+
248
+
249
+ def dump(data):
250
+ return (json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
251
+
252
+
253
+ def statusline_sync(name, store, home, record, desired, python, dry):
254
+ spec = STATUSLINES[name]
255
+ if spec["requires"] and not (home.joinpath(*spec["requires"])).is_dir() and not record:
256
+ return None, "skip" if desired else "unchanged", (spec["label"] + ": CLI não configurado neste perfil; nada alterado."
257
+ if desired else None)
258
+ path = protect(home.joinpath(*spec["path"]), home)
259
+ old, settings, problem = read_json_object(path, spec["label"])
260
+ if problem:
261
+ return (record if desired else None), "skip", problem
138
262
  current = settings.get("statusLine")
139
- chain_file = root(store) / "claude-statusline-chain.json"
263
+ chain_file = root(store) / spec["chain"]
140
264
  if not desired:
141
265
  if not record:
142
266
  return None, "unchanged", None
@@ -150,15 +274,7 @@ def claude_sync(store, home, record, desired, python, dry):
150
274
  settings.pop("statusLine", None)
151
275
  if dry:
152
276
  return None, "remove", None
153
- original = None if record.get("created_file") else restore_bytes(store, record, path, old)
154
- backup(store, "claude-settings", old)
155
- if record.get("created_file") and not settings:
156
- path.unlink()
157
- elif original is not None:
158
- # Nothing else changed since the install: give the file back byte for byte.
159
- write(path, original, old)
160
- else:
161
- write(path, (json.dumps(settings, ensure_ascii=False, indent=2) + "\n").encode("utf-8"), old)
277
+ release(store, name, path, old, spec["backup"], settings)
162
278
  if chain_file.exists():
163
279
  chain_file.unlink()
164
280
  return None, "remove", None
@@ -169,14 +285,14 @@ def claude_sync(store, home, record, desired, python, dry):
169
285
  if record and current != record["value"]:
170
286
  return record, "skip", "statusLine foi alterado depois da instalação; não sobrescrito. Rode crow-skills integrations uninstall para desfazer o controle."
171
287
  if not record and current is not None:
172
- if not (isinstance(current, dict) and current.get("type") == "command" and isinstance(current.get("command"), str)):
288
+ if not (isinstance(current, dict) and current.get("type", "command") == "command" and isinstance(current.get("command"), str)):
173
289
  return None, "skip", "statusLine existente não é do tipo command; não encadeado nem substituído."
174
290
  previous = current
175
- command = shlex.quote(str(python)) + " " + shlex.quote(str(script)) + " statusline"
291
+ command = shlex.quote(str(python)) + " " + shlex.quote(str(script)) + " statusline" + spec["args"]
176
292
  if previous is not None:
177
293
  command += " --chain-file " + shlex.quote(str(chain_file))
178
294
  # A chained status line keeps its own keys (padding and any other); only the command changes.
179
- value = {k: v for k, v in previous.items() if k not in ("type", "command")} if previous is not None else {"padding": 0}
295
+ value = {k: v for k, v in previous.items() if k not in ("type", "command")} if previous is not None else dict(spec["defaults"])
180
296
  value = {"type": "command", "command": command, **value}
181
297
  if record and current == value:
182
298
  return record, "unchanged", None
@@ -188,19 +304,278 @@ def claude_sync(store, home, record, desired, python, dry):
188
304
  return new_record, action, None
189
305
  if previous is not None:
190
306
  atomic_json(chain_file, {"command": previous["command"]})
191
- safety = backup(store, "claude-settings", old)
192
- # The restore point is the pre-install backup. An update keeps it only while the file
193
- # still holds exactly what this CLI wrote; otherwise uninstall restores the key only.
194
- if record:
195
- new_record["backup"] = record.get("backup") if record.get("written_sha") == sha(old or b"") else None
196
- else:
197
- new_record["backup"] = safety
198
- data = (json.dumps(settings, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
199
- write(path, data, old)
200
- new_record["written_sha"] = sha(data)
307
+ # The restore point is the file's pre-install backup (state["files"]). An update keeps it only
308
+ # while the file still holds exactly what this CLI wrote; otherwise uninstall restores the key only.
309
+ tracked_write(store, path, dump(settings), old, spec["backup"])
201
310
  return new_record, action, None
202
311
 
203
312
 
313
+ def claude_sync(store, home, record, desired, python, dry):
314
+ return statusline_sync("claude-statusline", store, home, record, desired, python, dry)
315
+
316
+
317
+ def agy_statusline_sync(store, home, record, desired, python, dry):
318
+ return statusline_sync("agy-statusline", store, home, record, desired, python, dry)
319
+
320
+
321
+ # --- hooks (crow mode + events) --------------------------------------------------------------
322
+
323
+ # (event, async). Only the prompt event (and a SessionStart that shows the mode) wait for the
324
+ # hook; every other event is recorded asynchronously where the CLI supports it.
325
+ CLAUDE_EVENTS = [("SessionStart", True), ("UserPromptSubmit", False), ("PreToolUse", True), ("PostToolUse", True),
326
+ ("PostToolUseFailure", True), ("PermissionRequest", True), ("Notification", True), ("SubagentStart", True),
327
+ ("SubagentStop", True), ("Stop", True), ("StopFailure", True), ("PreCompact", True), ("SessionEnd", True)]
328
+ CODEX_EVENTS = [("SessionStart", False), ("UserPromptSubmit", False), ("PreToolUse", True), ("PostToolUse", True),
329
+ ("PermissionRequest", True), ("SubagentStart", True), ("SubagentStop", True), ("Stop", True)]
330
+ GEMINI_EVENTS = ["SessionStart", "BeforeAgent", "BeforeTool", "AfterTool", "AfterAgent", "Notification", "PreCompress", "SessionEnd"]
331
+ AGY_EVENTS = ["SessionStart", "PreToolUse", "PostToolUse", "PreInvocation", "PostInvocation", "Stop"]
332
+ DEVIN_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "SubagentStop", "Stop",
333
+ "SessionEnd"]
334
+ AGY_KEY = "crow-skills"
335
+
336
+
337
+ def devin_dir(home):
338
+ if home == Path.home().resolve() and os.environ.get("XDG_CONFIG_HOME"):
339
+ return Path(os.environ["XDG_CONFIG_HOME"]).expanduser() / "devin"
340
+ return home / ".config" / "devin"
341
+
342
+
343
+ HOOKS = {
344
+ "claude-hooks": {"host": "claude", "file": lambda h: h / ".claude" / "settings.json", "label": "~/.claude/settings.json",
345
+ "layout": "groups", "requires": None, "backup": "claude-settings"},
346
+ "codex-hooks": {"host": "codex", "file": lambda h: h / ".codex" / "hooks.json", "label": "~/.codex/hooks.json",
347
+ "layout": "groups", "requires": lambda h: h / ".codex", "backup": "codex-hooks"},
348
+ "gemini-hooks": {"host": "gemini", "file": lambda h: h / ".gemini" / "settings.json", "label": "~/.gemini/settings.json",
349
+ "layout": "groups", "requires": lambda h: h / ".gemini" / "settings.json", "backup": "gemini-settings"},
350
+ "agy-hooks": {"host": "agy", "file": lambda h: h / ".gemini" / "config" / "hooks.json", "label": "~/.gemini/config/hooks.json",
351
+ "layout": "agy", "requires": lambda h: h / ".gemini" / "antigravity-cli", "backup": "agy-hooks"},
352
+ "devin-hooks": {"host": "devin", "file": lambda h: devin_dir(h) / "config.json", "label": "~/.config/devin/config.json",
353
+ "layout": "groups", "requires": lambda h: devin_dir(h) / "config.json", "backup": "devin-config"},
354
+ }
355
+
356
+
357
+ def codex_hooks_feature(home):
358
+ """Codex runs hooks only with [features] hooks = true; report it, never edit it here."""
359
+ try:
360
+ data = tomllib.loads((home / ".codex" / "config.toml").read_text(encoding="utf-8")) if tomllib else {}
361
+ except (OSError, ValueError):
362
+ data = {}
363
+ if (data.get("features") or {}).get("hooks") is True:
364
+ return ""
365
+ return " Habilite também [features] hooks = true em ~/.codex/config.toml (não alterado pelo crow-skills)."
366
+
367
+
368
+ def hook_command(store, home, python, host, event):
369
+ return (shlex.quote(str(python)) + " -I -S " + shlex.quote(str(hook_script_path(store))) + " hook --host " + host +
370
+ " --event " + event + " --home " + shlex.quote(str(home)))
371
+
372
+
373
+ def hook_entries(store, home, python, host):
374
+ """{event: [hook entry]} in the host's own schema."""
375
+ command = lambda event: hook_command(store, home, python, host, event) # noqa: E731
376
+ if host == "claude":
377
+ return {e: [dict({"type": "command", "command": command(e), "timeout": 5}, **({"async": True} if a else {}))]
378
+ for e, a in CLAUDE_EVENTS}
379
+ if host == "codex":
380
+ return {e: [dict({"type": "command", "command": command(e), "timeout": 5}, **({"async": True} if a else {}))]
381
+ for e, a in CODEX_EVENTS}
382
+ if host == "gemini":
383
+ return {e: [{"type": "command", "name": "crow-skills-" + e, "command": command(e), "timeout": 3000}] for e in GEMINI_EVENTS}
384
+ if host == "agy":
385
+ return {e: [{"type": "command", "command": command(e), "timeout": 3}] for e in AGY_EVENTS}
386
+ return {e: [{"type": "command", "command": command(e)}] for e in DEVIN_EVENTS}
387
+
388
+
389
+ def is_ours(entry, store):
390
+ return isinstance(entry, dict) and isinstance(entry.get("command"), str) and str(hook_script_path(store)) in entry["command"]
391
+
392
+
393
+ def strip_hooks(data, store, layout):
394
+ """A copy of the settings without this CLI's hook entries (user entries keep their order)."""
395
+ result = deepcopy(data)
396
+ if layout == "agy":
397
+ block = result.get(AGY_KEY)
398
+ if isinstance(block, dict) and all(isinstance(v, list) and all(is_ours(e, store) for e in v) for v in block.values()):
399
+ result.pop(AGY_KEY)
400
+ return result
401
+ hooks = result.get("hooks")
402
+ if not isinstance(hooks, dict):
403
+ return result
404
+ for event in list(hooks):
405
+ groups = hooks[event]
406
+ if not isinstance(groups, list):
407
+ continue
408
+ kept = []
409
+ for group in groups:
410
+ if isinstance(group, dict) and isinstance(group.get("hooks"), list):
411
+ remaining = [h for h in group["hooks"] if not is_ours(h, store)]
412
+ if not remaining and group["hooks"]:
413
+ continue # a group that held only our entries
414
+ if len(remaining) != len(group["hooks"]):
415
+ group = dict(group, hooks=remaining)
416
+ kept.append(group)
417
+ if kept:
418
+ hooks[event] = kept
419
+ elif groups:
420
+ hooks.pop(event)
421
+ if not hooks and data.get("hooks"):
422
+ result.pop("hooks") # emptied by removing our entries: the key was ours to add
423
+ return result
424
+
425
+
426
+ def refresh_in_place(data, entries, store, layout):
427
+ """Replace our existing entries where they are (same events, one entry each), so a re-sync
428
+ never moves the user's hooks or ours; None when the layout does not match the desired one."""
429
+ result = deepcopy(data)
430
+ if layout == "agy":
431
+ if not isinstance(result.get(AGY_KEY), dict):
432
+ return None
433
+ result[AGY_KEY] = entries # an existing key keeps its position
434
+ return result
435
+ hooks = result.get("hooks")
436
+ if not isinstance(hooks, dict):
437
+ return None
438
+ found = {}
439
+ for event, groups in hooks.items():
440
+ for gi, group in enumerate(groups if isinstance(groups, list) else []):
441
+ for hi, item in enumerate(group.get("hooks", []) if isinstance(group, dict) else []):
442
+ if is_ours(item, store):
443
+ found.setdefault(event, []).append((gi, hi))
444
+ if set(found) != set(entries) or any(len(v) != 1 or len(entries[e]) != 1 for e, v in found.items()):
445
+ return None
446
+ for event, [(gi, hi)] in found.items():
447
+ hooks[event][gi]["hooks"][hi] = entries[event][0]
448
+ return result
449
+
450
+
451
+ def add_hooks(data, entries, layout):
452
+ result = deepcopy(data)
453
+ if layout == "agy":
454
+ result[AGY_KEY] = entries
455
+ return result
456
+ hooks = result.setdefault("hooks", {})
457
+ for event, items in entries.items():
458
+ hooks.setdefault(event, []).append({"hooks": items}) # appended: the user's hooks keep their index (Codex trust)
459
+ return result
460
+
461
+
462
+ def hooks_sync(name, store, home, record, desired, python, dry):
463
+ spec = HOOKS[name]
464
+ target = spec["file"](home)
465
+ if desired and not record and spec["requires"] is not None and not os.path.exists(spec["requires"](home)):
466
+ return None, "skip", spec["label"] + ": CLI não configurado neste perfil; nada alterado."
467
+ path = protect(target, home)
468
+ old, data, problem = read_json_object(path, spec["label"])
469
+ if problem:
470
+ return (record if desired else None), "skip", problem
471
+ if spec["layout"] == "groups" and not isinstance(data.get("hooks", {}), dict):
472
+ return (record if desired else None), "skip", spec["label"] + ": chave hooks com formato inesperado; nada alterado."
473
+ stripped = strip_hooks(data, store, spec["layout"])
474
+ if not desired:
475
+ if not record:
476
+ return None, "unchanged", None
477
+ if stripped == data:
478
+ return None, "skip", "Entradas do crow-skills já ausentes de " + spec["label"] + "; controle encerrado."
479
+ if dry:
480
+ return None, "remove", None
481
+ release(store, name, path, old, spec["backup"], stripped)
482
+ return None, "remove", None
483
+ if not quoted_ok(python, hook_script_path(store), home):
484
+ return record, "skip", "Caminho do Python, do estado ou do home contém aspas/variáveis; hooks não configurados."
485
+ entries = hook_entries(store, home, python, spec["host"])
486
+ wanted = refresh_in_place(data, entries, store, spec["layout"]) or add_hooks(stripped, entries, spec["layout"])
487
+ if wanted == data:
488
+ if record:
489
+ return record, "unchanged", None
490
+ return {"path": str(path), "installed_at": now(), "created_file": False}, "adopt", None
491
+ action = "update" if record else "install"
492
+ new_record = {"path": str(path), "installed_at": now(),
493
+ "created_file": record.get("created_file", False) if record else old is None}
494
+ note = None
495
+ if spec["host"] == "codex":
496
+ note = ("Codex só executa hook novo ou alterado depois de aprovado: abra o Codex, rode /hooks e aprove as entradas "
497
+ "do crow-skills (o hook do usuário já aprovado continua no mesmo índice).") + codex_hooks_feature(home)
498
+ if dry:
499
+ return new_record, action, note
500
+ tracked_write(store, path, dump(wanted), old, spec["backup"])
501
+ return new_record, action, note
502
+
503
+
504
+ # --- /crow slash commands ------------------------------------------------------------------------
505
+
506
+ COMMANDS = {
507
+ "claude-command": {"file": (".claude", "commands", "crow.md"), "requires": None, "host": "claude"},
508
+ "gemini-command": {"file": (".gemini", "commands", "crow.toml"), "requires": (".gemini", "settings.json"), "host": "gemini"},
509
+ }
510
+
511
+
512
+ def command_content(name, store, home, python):
513
+ call = (shlex.quote(str(python)) + " -I -S " + shlex.quote(str(hook_script_path(store))) + " mode")
514
+ tail = " --home " + shlex.quote(str(home)) + " --by " + COMMANDS[name]["host"]
515
+ if name == "claude-command":
516
+ return ("---\n"
517
+ "description: Liga ou desliga o modo crow (crow-behaviour) em todas as sessões e CLIs. Uso: /crow on | off | status\n"
518
+ "argument-hint: on | off | status\n"
519
+ "allowed-tools: Bash(" + call + ":*)\n"
520
+ "disable-model-invocation: true\n"
521
+ "---\n"
522
+ "<!-- " + COMMAND_HEADER + " -->\n\n"
523
+ "!`case \"$ARGUMENTS\" in on|off|status|toggle|'') " + call + " $ARGUMENTS" + tail + " ;; *) echo 'uso: /crow on|off|status' ;; esac`\n\n"
524
+ "Responda ao usuário só com a linha acima (o estado do modo crow), sem carregar skills nem executar mais nada.\n")
525
+ return ("# " + COMMAND_HEADER + "\n"
526
+ 'description = "Liga ou desliga o modo crow (crow-behaviour) em todas as sessões e CLIs: /crow on | off | status"\n'
527
+ 'prompt = """\n'
528
+ "!{" + call + " {{args}}" + tail + "}\n\n"
529
+ "Responda ao usuário só com a linha acima (o estado do modo crow), sem carregar skills nem executar mais nada.\n"
530
+ '"""\n')
531
+
532
+
533
+ def command_sync(name, store, home, record, desired, python, dry):
534
+ spec = COMMANDS[name]
535
+ path = home.joinpath(*spec["file"])
536
+ if desired and not record and spec["requires"] and not home.joinpath(*spec["requires"]).exists():
537
+ return None, "skip", "/".join(spec["requires"]) + " ausente: CLI não configurado neste perfil; nada alterado."
538
+ path = protect(path, home)
539
+ current = read_regular(path) if os.path.lexists(path) else None
540
+ managed = current is not None and COMMAND_HEADER.encode("utf-8") in current
541
+ if not desired:
542
+ if not record:
543
+ return None, "unchanged", None
544
+ if current is None or not managed or sha(current) != record.get("written_sha"):
545
+ return None, "skip", str(path) + " foi alterado ou removido; mantido como está e removido do controle do crow-skills."
546
+ if dry:
547
+ return None, "remove", None
548
+ path.unlink()
549
+ for directory in record.get("created_dirs", []):
550
+ try:
551
+ os.rmdir(directory)
552
+ except OSError:
553
+ pass
554
+ return None, "remove", None
555
+ if current is not None and not managed:
556
+ return record, "skip", str(path) + " existe e não é gerenciado; comando /crow não instalado."
557
+ if record and current is not None and sha(current) != record.get("written_sha"):
558
+ return record, "skip", str(path) + " foi editado depois da instalação; não sobrescrito."
559
+ if not quoted_ok(python, hook_script_path(store), home):
560
+ return record, "skip", "Caminho do Python, do estado ou do home contém aspas/variáveis; comando /crow não instalado."
561
+ content = command_content(name, store, home, python).encode("utf-8")
562
+ if record and current == content:
563
+ return record, "unchanged", None
564
+ created = record.get("created_dirs", []) if record else []
565
+ if not record:
566
+ parent = path.parent
567
+ while not parent.exists() and home in parent.parents:
568
+ created.append(str(parent))
569
+ parent = parent.parent
570
+ new_record = {"path": str(path), "installed_at": now(), "written_sha": sha(content), "created_dirs": created}
571
+ if dry:
572
+ return new_record, "update" if record else "install", None
573
+ if not path.parent.exists():
574
+ path.parent.mkdir(parents=True, mode=0o755)
575
+ write_atomic(path, content, 0o644)
576
+ return new_record, "update" if record else "install", None
577
+
578
+
204
579
  # --- codex ---------------------------------------------------------------------------------
205
580
 
206
581
  def marked(text):
@@ -448,7 +823,10 @@ def tmux_sync(store, home, record, desired, python, dry):
448
823
  return new_record, action, note
449
824
 
450
825
 
451
- SYNC = {"claude-statusline": claude_sync, "codex-statusline": codex_sync, "tmux-status": tmux_sync}
826
+ SYNC = {"claude-statusline": claude_sync, "codex-statusline": codex_sync, "tmux-status": tmux_sync,
827
+ "agy-statusline": agy_statusline_sync,
828
+ **{name: (lambda n: lambda *a: hooks_sync(n, *a))(name) for name in HOOKS},
829
+ **{name: (lambda n: lambda *a: command_sync(n, *a))(name) for name in COMMANDS}}
452
830
 
453
831
 
454
832
  # --- orchestration -------------------------------------------------------------------------
@@ -466,15 +844,27 @@ def sync(store, home, wanted, dry_run=False, python=None):
466
844
  except CrowError as exc:
467
845
  report["skipped"].append({"integration": "*", "reason": str(exc)})
468
846
  return report
469
- after = {"schema_version": 1, "integrations": dict(state["integrations"])}
847
+ after = {"schema_version": 1, "integrations": dict(state["integrations"]), "files": dict(state.get("files") or {})}
848
+ for record in state["integrations"].values():
849
+ # Records written by 0.7.x kept the restore point in the record itself.
850
+ if isinstance(record, dict) and record.get("written_sha") and record.get("path") and record["path"] not in after["files"] \
851
+ and str(record["path"]).endswith(".json"):
852
+ after["files"][record["path"]] = {"backup": record.get("backup"), "created_file": record.get("created_file", False),
853
+ "written_sha": record["written_sha"]}
854
+ global _FILES, _LIVE
855
+ _FILES = {k: dict(v) for k, v in after["files"].items()}
856
+ _LIVE = after["integrations"]
470
857
  with (nullcontext() if dry_run else store.lock()):
471
858
  if not dry_run and any(set(OWNERS[n]) & set(wanted) for n in NAMES):
472
- script = script_path(store)
473
859
  private_dir(root(store))
474
- source = script_source()
475
- if not script.exists() or sha(script.read_bytes()) != sha(source):
476
- write_atomic(checked(script), source, 0o644)
477
- for name in NAMES:
860
+ for script, source in scripts(store):
861
+ if not script.exists() or sha(script.read_bytes()) != sha(source):
862
+ write_atomic(checked(script), source, 0o644)
863
+ # Removals first and in reverse order, then installs in order: two integrations that
864
+ # edit the same file (claude settings.json) restore it last-in, first-out, byte for byte.
865
+ order = [n for n in reversed(NAMES) if not set(OWNERS[n]) & set(wanted)] + \
866
+ [n for n in NAMES if set(OWNERS[n]) & set(wanted)]
867
+ for name in order:
478
868
  desired = bool(set(OWNERS[name]) & set(wanted))
479
869
  record = state["integrations"].get(name)
480
870
  try:
@@ -493,13 +883,20 @@ def sync(store, home, wanted, dry_run=False, python=None):
493
883
  after["integrations"].pop(name, None)
494
884
  else:
495
885
  after["integrations"][name] = new_record
886
+ after["files"] = {k: v for k, v in _FILES.items()
887
+ if any(isinstance(r, dict) and r.get("path") == k for r in after["integrations"].values())}
888
+ if not after["files"]:
889
+ after.pop("files")
496
890
  if not dry_run:
497
891
  if after != state:
498
892
  atomic_json(state_path(store), after)
499
893
  if not after["integrations"]:
500
- script = script_path(store)
501
- if script.exists():
502
- script.unlink()
894
+ for script in (script_path(store), hook_script_path(store), resources_script_path(store)):
895
+ if script.exists():
896
+ script.unlink()
897
+ remove_bytecode(store)
898
+ for key in ("changes", "skipped"):
899
+ report[key].sort(key=lambda c: NAMES.index(c["integration"]) if c["integration"] in NAMES else 99)
503
900
  report["dry_run"] = dry_run
504
901
  return report
505
902
 
@@ -516,5 +913,5 @@ def status(store, home):
516
913
  row["note"] = "tmux.conf é symlink; linha source-file adicionada manualmente pelo usuário."
517
914
  rows.append(row)
518
915
  for agent, reason in UNSUPPORTED.items():
519
- rows.append({"integration": agent + "-statusline", "agents": [agent], "status": "unsupported", "note": reason})
916
+ rows.append({"integration": UNSUPPORTED_ROW[agent], "agents": [agent], "status": "unsupported", "note": reason})
520
917
  return rows