@andespindola/crow-skills 0.7.1 → 0.8.0

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