@andespindola/crow-skills 0.1.0 → 0.4.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.
@@ -0,0 +1,585 @@
1
+ """Managed, additive agent guidance in each CLI's native always-loaded location.
2
+
3
+ Only mechanisms proven with a sentinel in a fresh interactive session of the
4
+ CLI are supported, all of them at user scope and all additive: the CLI keeps
5
+ loading its existing base/shared prompt next to this guide. Shared prompts
6
+ (for example a ~/.claude/CLAUDE.md symlink) and their targets are never
7
+ written, and mechanisms that replace a base prompt are not used.
8
+
9
+ Ownership: a whole file that this CLI creates (claude, gemini, devin) or a
10
+ single marked block holding one top-level key in a config file (codex). The
11
+ rest of a shared config file is preserved byte for byte.
12
+ """
13
+ from copy import deepcopy
14
+ from datetime import datetime, timezone
15
+ import hashlib
16
+ import os
17
+ from pathlib import Path
18
+ import re
19
+ import stat
20
+ import uuid
21
+
22
+ try: # Python 3.11+; required only to verify config.toml edits.
23
+ import tomllib
24
+ except ImportError: # pragma: no cover - CI and supported hosts have 3.11+
25
+ tomllib = None
26
+
27
+ from .agents import destinations
28
+ from .source import CrowError, SHA
29
+ from .storage import atomic_json, checked, private_dir, read_json
30
+
31
+ HEADER = "<!-- Gerenciado por crow-skills guide. Não edite; use crow-skills guide install/uninstall. -->"
32
+ BEGIN = "# >>> crow-skills guide (gerenciado; use crow-skills guide uninstall) >>>"
33
+ END = "# <<< crow-skills guide <<<"
34
+ TOML_KEY = "developer_instructions"
35
+ MAX_CONFIG = 4 * 1024 * 1024
36
+ HEX = re.compile(r"[a-f0-9]{64}\Z")
37
+ PRESERVED = re.compile(r"[a-f0-9]{32}/[a-z]+\.bak\Z")
38
+
39
+ # path is relative to the user's home. All entries were verified on 2026-10-21
40
+ # with a sentinel loaded in a fresh session alongside the existing base prompt.
41
+ MECHANISMS = {
42
+ "claude": {"kind": "file", "path": ".claude/rules/crow-skills.md", "frontmatter": False,
43
+ "label": "Claude Code", "invoke": "/{name}",
44
+ "load": "use a ferramenta Skill com o nome da skill ou digite /nome"},
45
+ "codex": {"kind": "toml", "path": ".codex/config.toml", "label": "Codex CLI", "invoke": "${name}",
46
+ "load": "mencione $nome ou abra o SKILL.md dela pela lista de skills disponíveis"},
47
+ "gemini": {"kind": "file", "path": ".gemini/config/rules/crow-skills.md", "frontmatter": True,
48
+ "label": "Antigravity CLI, agy", "invoke": "/{name}",
49
+ "load": "use a ferramenta de skills do agy ou digite /nome"},
50
+ "devin": {"kind": "file", "path": ".devin/rules/crow-skills.md", "frontmatter": True,
51
+ "label": "Devin CLI", "invoke": "/devin:{name}",
52
+ "load": "use a ferramenta de skills do Devin ou digite /devin:nome"},
53
+ }
54
+ UNVERIFIED = {
55
+ "shared": "shared é um diretório de skills, não um CLI; não há onde gravar instruções.",
56
+ }
57
+ # Shared prompt files (often symlinks into ~/.config/uniflow) and their targets.
58
+ PROTECTED = (".claude/CLAUDE.md", ".codex/AGENTS.md", ".codex/AGENTS.override.md", ".gemini/GEMINI.md",
59
+ ".config/devin/CLAUDE.md", ".config/devin/AGENTS.md", ".config/uniflow")
60
+
61
+
62
+ def sha(data):
63
+ if data is None:
64
+ return None
65
+ if isinstance(data, str):
66
+ data = data.encode("utf-8")
67
+ return hashlib.sha256(data).hexdigest()
68
+
69
+
70
+ def now():
71
+ return datetime.now(timezone.utc).isoformat()
72
+
73
+
74
+ def select(agents):
75
+ if not agents:
76
+ raise CrowError("usage", "Selecione --agents; não há instalação implícita em todos os agentes.")
77
+ names = agents.split(",") if isinstance(agents, str) else list(agents)
78
+ result = []
79
+ for name in (n.strip() for n in names):
80
+ if name in UNVERIFIED:
81
+ raise CrowError("unsupported_guide", "Sem mecanismo verificado para " + name + ": " + UNVERIFIED[name])
82
+ if name not in MECHANISMS:
83
+ raise CrowError("unsupported_guide", "Sem mecanismo nativo verificado para '" + name +
84
+ "'. Suportados: " + ",".join(MECHANISMS) + ".")
85
+ if name not in result:
86
+ result.append(name)
87
+ return result
88
+
89
+
90
+ def protect(path, home):
91
+ """Refuse symlinked components, shared prompts and anything inside their targets."""
92
+ path = checked(path)
93
+ for rel in PROTECTED:
94
+ guard = Path(os.path.abspath(home / rel))
95
+ guards = {guard}
96
+ if os.path.lexists(guard):
97
+ guards.add(Path(os.path.realpath(guard)))
98
+ for item in guards:
99
+ if path == item or path.is_relative_to(item):
100
+ raise CrowError("protected_path", "Arquivo de prompt compartilhado (ou alvo dele) não é gravado: " + str(path))
101
+ return path
102
+
103
+
104
+ def read_regular(path):
105
+ if not os.path.lexists(path):
106
+ return None
107
+ info = path.lstat()
108
+ if not stat.S_ISREG(info.st_mode):
109
+ raise CrowError("unsafe_destination", "Destino do guia não é arquivo regular: " + str(path))
110
+ if info.st_nlink > 1:
111
+ raise CrowError("unsafe_destination", "Destino do guia tem hard links; recusado: " + str(path))
112
+ if info.st_size > MAX_CONFIG:
113
+ raise CrowError("unsafe_destination", "Destino do guia excede o limite de leitura segura.")
114
+ return path.read_bytes()
115
+
116
+
117
+ def write_atomic(path, data, mode):
118
+ temp = path.parent / ("." + path.name + ".crow-" + uuid.uuid4().hex)
119
+ try:
120
+ fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
121
+ with os.fdopen(fd, "wb") as f:
122
+ f.write(data)
123
+ f.flush()
124
+ os.fsync(f.fileno())
125
+ os.chmod(temp, mode)
126
+ os.replace(temp, path)
127
+ fd = os.open(path.parent, os.O_RDONLY)
128
+ try:
129
+ os.fsync(fd)
130
+ finally:
131
+ os.close(fd)
132
+ finally:
133
+ if os.path.lexists(temp):
134
+ temp.unlink()
135
+
136
+
137
+ # --- content -----------------------------------------------------------------
138
+
139
+ def clean(text):
140
+ return "".join(c for c in str(text) if c.isprintable()).replace("'''", "'").replace("-->", "->")
141
+
142
+
143
+ def managed_skills(agent, home, store):
144
+ """crow-* packages this CLI manages for the agent (edited copies still count)."""
145
+ target = destinations(agent, home)[0]
146
+ return sorted(r["name"] for r in store.status([target], ()) if r["status"] in ("installed", "modified"))
147
+
148
+
149
+ def present_skills(agent, home, store, catalog):
150
+ return [n for n in managed_skills(agent, home, store) if n in catalog]
151
+
152
+
153
+ def render_body(agent, catalog, revision, skills):
154
+ mech = MECHANISMS[agent]
155
+ invoke = mech["invoke"].format
156
+ specialists = [n for n in skills if n != "crow-behaviour"]
157
+ example = " (por exemplo, " + invoke(name=specialists[0]) + ")" if specialists else ""
158
+ lines = [
159
+ "# Crow skills neste agente (" + mech["label"] + ")",
160
+ "",
161
+ "Gerado por crow-skills guide a partir do catálogo " + revision[:12] + ". Complementa as instruções do usuário e do projeto; havendo conflito, elas prevalecem.",
162
+ "",
163
+ "## crow-behaviour: quando aplicar",
164
+ "- Tarefa substantiva (várias etapas ou partes, exploração e decisões, mudanças em vários arquivos ou componentes, ou entrega que exige verificação): carregue a skill crow-behaviour ANTES de começar, por iniciativa própria, sem esperar pedido. Para carregar, " + mech["load"] + "; por exemplo, " + invoke(name="crow-behaviour") + ". Siga-a até a entrega.",
165
+ "- Vale também quando a entrega é só um plano, um diagnóstico ou uma revisão, ou quando não há código no diretório: depurar, auditar, investigar, migrar ou planejar uma mudança com várias etapas continua sendo tarefa substantiva.",
166
+ "- Conversa, pergunta factual curta ou ajuste trivial e local: responda direto, sem carregá-la.",
167
+ "",
168
+ "## Skills especialistas",
169
+ ]
170
+ lines += [
171
+ "- Antes de executar, consulte a lista de skills disponíveis neste agente e carregue pelo nome a especialista cujo domínio corresponde à tarefa" + example + ". Se a tarefa cruzar domínios, combine as pertinentes; a crow-behaviour coordena, a especialista executa.",
172
+ "- Handoffs entre skills são sugestões opcionais, não dependências; não carregue a biblioteca inteira.",
173
+ "- Se uma skill não estiver disponível no host, diga isso e siga sem ela; nunca simule o conteúdo de uma skill que não carregou.",
174
+ "",
175
+ "## Crow skills gerenciadas neste agente",
176
+ ]
177
+ for name in skills:
178
+ lines.append("- " + invoke(name=name) + " — " + clean(catalog[name].get("title") or catalog[name]["owns"]))
179
+ return "\n".join(lines) + "\n"
180
+
181
+
182
+ def managed_content(agent, body):
183
+ """Exact bytes owned by this CLI: a whole file, or one marked TOML block."""
184
+ mech = MECHANISMS[agent]
185
+ if mech["kind"] == "toml":
186
+ if "'''" in body or any(not (c.isprintable() or c in "\n\t") for c in body):
187
+ raise CrowError("invalid_guide", "Conteúdo do guia não cabe numa string TOML literal.")
188
+ return BEGIN + "\n" + TOML_KEY + " = '''\n" + body + "'''\n" + END + "\n"
189
+ front = "---\ntrigger: always_on\n---\n\n" if mech["frontmatter"] else ""
190
+ return front + HEADER + "\n\n" + body
191
+
192
+
193
+ # --- codex config.toml block ------------------------------------------------------
194
+
195
+ def parse_toml(text):
196
+ if tomllib is None:
197
+ raise CrowError("unsupported_platform", "Python 3.11+ (tomllib) é necessário para editar config.toml com verificação.")
198
+ try:
199
+ return tomllib.loads(text)
200
+ except tomllib.TOMLDecodeError as exc:
201
+ raise CrowError("invalid_config", "config.toml não é TOML válido; nada alterado.") from exc
202
+
203
+
204
+ def find_block(text):
205
+ """Return (start, end) offsets of the managed block, or None."""
206
+ begins = [m.start() for m in re.finditer(r"(?m)^" + re.escape(BEGIN) + r"\n", text)]
207
+ ends = [m.end() for m in re.finditer(r"(?m)^" + re.escape(END) + r"\n", text)]
208
+ if not begins and not ends:
209
+ return None
210
+ if len(begins) != 1 or len(ends) != 1 or ends[0] <= begins[0]:
211
+ raise CrowError("guide_corrupted", "Marcadores do bloco crow-skills em config.toml estão incompletos ou duplicados; revise manualmente.")
212
+ return begins[0], ends[0]
213
+
214
+
215
+ def decode(data):
216
+ try:
217
+ return data.decode("utf-8")
218
+ except UnicodeDecodeError as exc:
219
+ raise CrowError("invalid_config", "config.toml não é UTF-8; nada alterado.") from exc
220
+
221
+
222
+ def toml_block(data):
223
+ if data is None:
224
+ return None
225
+ text = decode(data)
226
+ span = find_block(text)
227
+ return text[span[0]:span[1]] if span else None
228
+
229
+
230
+ def toml_compose(old_bytes, old_block, new_block, body=None):
231
+ """Remove the existing managed block and prepend the new one; verify parse."""
232
+ text = decode(old_bytes) if old_bytes is not None else ""
233
+ if old_block is not None:
234
+ start, end = find_block(text)
235
+ text = text[:start] + text[end:]
236
+ base = parse_toml(text)
237
+ if TOML_KEY in base:
238
+ raise CrowError("unmanaged_conflict", "config.toml já define " + TOML_KEY + " fora do bloco gerenciado; remova ou mova antes. Nada alterado.")
239
+ result = (new_block or "") + text
240
+ parsed = parse_toml(result)
241
+ expected = dict(base)
242
+ if new_block is not None:
243
+ expected[TOML_KEY] = body if body is not None else parse_toml(new_block)[TOML_KEY]
244
+ if parsed != expected:
245
+ raise CrowError("invalid_config", "Edição do config.toml não preservou o restante do arquivo; nada alterado.")
246
+ return result.encode("utf-8")
247
+
248
+
249
+ # --- state ----------------------------------------------------------------------
250
+
251
+ def state_path(store):
252
+ return store.root / "guides" / "state.json"
253
+
254
+
255
+ def validate_state(data):
256
+ try:
257
+ if data["schema_version"] != 1 or not isinstance(data["guides"], dict):
258
+ raise ValueError("schema")
259
+ for agent, record in data["guides"].items():
260
+ if agent not in MECHANISMS or record["mechanism"] != MECHANISMS[agent]["kind"]:
261
+ raise ValueError("agent")
262
+ if (not isinstance(record["path"], str) or not HEX.fullmatch(record["sha256"]) or
263
+ not SHA.fullmatch(record["revision"]) or not isinstance(record["skills"], list) or
264
+ not all(isinstance(d, str) for d in record.get("created_dirs", [])) or
265
+ not isinstance(record.get("created_file", False), bool)):
266
+ raise ValueError("record")
267
+ preserved = record.get("preserved")
268
+ if preserved is not None and (not HEX.fullmatch(preserved["sha256"]) or
269
+ not PRESERVED.fullmatch(preserved.get("backup") or "")):
270
+ raise ValueError("preserved")
271
+ except (KeyError, TypeError, ValueError) as exc:
272
+ raise CrowError("invalid_state", "Estado de guias inválido; nenhuma alteração aplicada.") from exc
273
+ return data
274
+
275
+
276
+ def load_state(store):
277
+ path = checked(state_path(store))
278
+ if not path.exists():
279
+ return {"schema_version": 1, "guides": {}}
280
+ return validate_state(read_json(path))
281
+
282
+
283
+ def validate_journal(data, txid):
284
+ try:
285
+ if data["id"] != txid or data["phase"] not in ("prepared", "restoring", "committed", "rolled_back"):
286
+ raise ValueError("phase")
287
+ validate_state(data["before"])
288
+ validate_state(data["after"])
289
+ if "linked" in data and not re.fullmatch(r"[a-f0-9]{32}", data["linked"] or ""):
290
+ raise ValueError("linked")
291
+ if not isinstance(data["items"], list) or not data["items"]:
292
+ raise ValueError("items")
293
+ for item in data["items"]:
294
+ if item["agent"] not in MECHANISMS or item["kind"] != MECHANISMS[item["agent"]]["kind"]:
295
+ raise ValueError("agent")
296
+ if str(checked(item["path"])) != item["path"]:
297
+ raise ValueError("path")
298
+ for key in ("old_sha", "new_sha"):
299
+ if item[key] is not None and not HEX.fullmatch(item[key]):
300
+ raise ValueError("sha")
301
+ for key in ("old_block", "new_block"):
302
+ if item[key] is not None and not isinstance(item[key], str):
303
+ raise ValueError("block")
304
+ if item["backup"] is not None and not re.fullmatch(r"[a-z]+\.bak", item["backup"]):
305
+ raise ValueError("backup")
306
+ item.setdefault("removed_dirs", [])
307
+ for key in ("created_dirs", "removed_dirs"):
308
+ if not isinstance(item[key], list) or not all(isinstance(d, str) and str(checked(d)) == d for d in item[key]):
309
+ raise ValueError("dirs")
310
+ except (KeyError, TypeError, ValueError) as exc:
311
+ raise CrowError("invalid_transaction", "Registro transacional de guia inconsistente; recuperação recusada.") from exc
312
+ return data
313
+
314
+
315
+ # --- plan / apply / restore -----------------------------------------------------------
316
+
317
+ def current(agent, home):
318
+ """(path, file bytes, managed bytes-or-text) for an agent; never follows links."""
319
+ mech = MECHANISMS[agent]
320
+ path = protect(home / mech["path"], home)
321
+ data = read_regular(path)
322
+ if mech["kind"] == "toml":
323
+ return path, data, toml_block(data)
324
+ return path, data, data.decode("utf-8", "replace") if data is not None else None
325
+
326
+
327
+ def plan(store, action, agents, home, source=None, replace=False, overwrite=False, skills_after=None):
328
+ """Plan guide changes. action is "install"/"uninstall" or a {agent: action} map.
329
+
330
+ skills_after maps an agent to the managed skills it will have once a pending
331
+ skills transaction applies; without it the current installation is used.
332
+ """
333
+ if store.pending():
334
+ raise CrowError("recovery_required", "Há uma transação interrompida. Execute recover antes de alterar instalações.")
335
+ actions = action if isinstance(action, dict) else {agent: action for agent in agents}
336
+ before = load_state(store)
337
+ after = deepcopy(before)
338
+ items, changes, payload, previews = [], [], {}, {}
339
+ for agent in agents:
340
+ action = actions[agent]
341
+ mech = MECHANISMS[agent]
342
+ path, data, managed = current(agent, home)
343
+ record = before["guides"].get(agent)
344
+ if record and record["path"] != str(path):
345
+ raise CrowError("invalid_state", "Guia registrado em outro caminho para " + agent + "; revise o estado.")
346
+ managed_sha = sha(managed)
347
+ if action == "uninstall":
348
+ if not record:
349
+ raise CrowError("not_managed", "Guia não gerenciado para " + agent + "; nada removido.")
350
+ if managed_sha != record["sha256"] and not overwrite:
351
+ raise CrowError("local_changes", "Guia de " + agent + " alterado/ausente. Revise; --overwrite-modified autoriza backup e remoção.")
352
+ new_managed, body, skills = None, None, None
353
+ original = preserved_original(store, record)
354
+ else:
355
+ if "crow-behaviour" not in source.catalog:
356
+ raise CrowError("invalid_catalog", "Catálogo sem crow-behaviour.")
357
+ if skills_after is not None and agent in skills_after:
358
+ skills = [n for n in skills_after[agent] if n in source.catalog]
359
+ else:
360
+ skills = present_skills(agent, home, store, source.catalog)
361
+ if "crow-behaviour" not in skills:
362
+ raise CrowError("guide_requires_skill", "crow-behaviour não está instalada para " + agent +
363
+ "; instale antes: crow-skills install crow-behaviour --agents " + agent + " --global --yes")
364
+ body = render_body(agent, source.catalog, source.revision, skills)
365
+ new_managed = managed_content(agent, body)
366
+ previews[agent] = new_managed
367
+ if record and managed_sha == sha(new_managed):
368
+ after["guides"][agent].update(revision=source.revision, skills=skills)
369
+ changes.append({"agent": agent, "path": str(path), "mechanism": mech["kind"], "action": "unchanged"})
370
+ continue
371
+ if record and managed_sha != record["sha256"] and not overwrite:
372
+ raise CrowError("local_changes", "Guia de " + agent + " alterado/ausente desde a instalação. Revise; --overwrite-modified autoriza backup e substituição.")
373
+ if not record and managed is not None and not replace:
374
+ raise CrowError("unmanaged_conflict", "Já existe conteúdo não gerenciado em " + str(path) + ". Revise; --replace autoriza backup e substituição.")
375
+ if mech["kind"] == "toml":
376
+ new_data = toml_compose(data, managed, new_managed, body)
377
+ if new_data == b"" and new_managed is None and record.get("created_file", False):
378
+ new_data = None # This CLI created the file and only our block is left: remove it.
379
+ elif new_managed is not None:
380
+ new_data = new_managed.encode("utf-8")
381
+ else:
382
+ new_data = original # Uninstall: put back what --replace displaced, or remove our file.
383
+ created = []
384
+ if new_data is not None:
385
+ parent = path.parent
386
+ while not parent.exists() and parent != home and home in parent.parents:
387
+ created.append(str(parent))
388
+ parent = parent.parent
389
+ mode = stat.S_IMODE(path.lstat().st_mode) if data is not None else (0o600 if mech["kind"] == "toml" else 0o644)
390
+ items.append({"agent": agent, "kind": mech["kind"], "path": str(path), "old_sha": sha(data), "new_sha": sha(new_data),
391
+ "old_block": managed if mech["kind"] == "toml" else None,
392
+ "new_block": new_managed if mech["kind"] == "toml" else None,
393
+ "mode": mode, "backup": None, "created_dirs": created,
394
+ "removed_dirs": record.get("created_dirs", []) if action == "uninstall" else []})
395
+ payload[agent] = (data, new_data)
396
+ if action == "uninstall":
397
+ after["guides"].pop(agent)
398
+ act = "remove"
399
+ else:
400
+ entry = {"path": str(path), "mechanism": mech["kind"], "sha256": sha(new_managed),
401
+ "revision": source.revision, "skills": skills, "installed_at": now(),
402
+ "created_dirs": created + (record.get("created_dirs", []) if record else [])}
403
+ if mech["kind"] == "toml":
404
+ entry["created_file"] = record.get("created_file", False) if record else data is None
405
+ if record and record.get("preserved"):
406
+ entry["preserved"] = record["preserved"]
407
+ elif not record and data is not None and mech["kind"] == "file":
408
+ entry["preserved"] = {"sha256": sha(data)} # backup id is set by apply()
409
+ after["guides"][agent] = entry
410
+ act = "replace" if managed is not None else "install"
411
+ changes.append({"agent": agent, "path": str(path), "mechanism": mech["kind"], "action": act,
412
+ "edits": "bloco " + TOML_KEY + " marcado" if mech["kind"] == "toml" else "arquivo próprio"})
413
+ return {"before": before, "after": after, "items": items, "payload": payload, "changes": changes, "previews": previews}
414
+
415
+
416
+ def apply(store, plan_data, home, linked=None):
417
+ """Apply a guide plan; linked is the skills transaction this one completes."""
418
+ if not plan_data["items"]:
419
+ if plan_data["after"] != plan_data["before"]:
420
+ with store.lock():
421
+ if load_state(store) != plan_data["before"]:
422
+ raise CrowError("changed_during_plan", "O estado mudou após o preview; execute novamente.")
423
+ atomic_json(state_path(store), plan_data["after"])
424
+ return None
425
+ with store.lock():
426
+ if store.pending():
427
+ raise CrowError("recovery_required", "Recuperação pendente; nenhuma alteração aplicada.")
428
+ if load_state(store) != plan_data["before"]:
429
+ raise CrowError("changed_during_plan", "O estado mudou após o preview; execute novamente.")
430
+ for item in plan_data["items"]:
431
+ if sha(read_regular(protect(Path(item["path"]), home))) != item["old_sha"]:
432
+ raise CrowError("changed_during_plan", "Um destino mudou após o preview; execute novamente.")
433
+ for item in plan_data["items"]:
434
+ # A skills transaction applied after planning may have created parents
435
+ # (for example ~/.claude); this transaction does not own those.
436
+ existing = [d for d in item["created_dirs"] if os.path.lexists(d)]
437
+ if existing:
438
+ item["created_dirs"] = [d for d in item["created_dirs"] if d not in existing]
439
+ entry = plan_data["after"]["guides"].get(item["agent"])
440
+ if entry is not None:
441
+ entry["created_dirs"] = [d for d in entry["created_dirs"] if d not in existing]
442
+ txid = uuid.uuid4().hex
443
+ backups = private_dir(store.root / "guide-backups") / txid
444
+ backups = private_dir(backups)
445
+ for item in plan_data["items"]:
446
+ old, _ = plan_data["payload"][item["agent"]]
447
+ if old is not None:
448
+ item["backup"] = item["agent"] + ".bak"
449
+ write_atomic(backups / item["backup"], old, 0o600)
450
+ kept = plan_data["after"]["guides"].get(item["agent"], {}).get("preserved")
451
+ if kept is not None and "backup" not in kept:
452
+ kept["backup"] = txid + "/" + item["backup"]
453
+ data = {"id": txid, "kind": "guide", "phase": "prepared", "created_at": now(),
454
+ "before": plan_data["before"], "after": plan_data["after"], "items": plan_data["items"]}
455
+ if linked:
456
+ data["linked"] = linked
457
+ journal = store.root / "transactions" / (txid + ".json")
458
+ atomic_json(journal, data)
459
+ try:
460
+ for item in plan_data["items"]:
461
+ path = protect(Path(item["path"]), home)
462
+ if sha(read_regular(path)) != item["old_sha"]:
463
+ raise CrowError("changed_during_install", "Destino do guia alterado durante a instalação.")
464
+ _, new = plan_data["payload"][item["agent"]]
465
+ if new is None:
466
+ if os.path.lexists(path):
467
+ path.unlink()
468
+ remove_empty(item["removed_dirs"])
469
+ else:
470
+ for directory in reversed(item["created_dirs"]):
471
+ os.mkdir(directory, 0o755)
472
+ write_atomic(path, new, item["mode"])
473
+ atomic_json(state_path(store), plan_data["after"])
474
+ data["phase"] = "committed"
475
+ atomic_json(journal, data)
476
+ except (Exception, KeyboardInterrupt):
477
+ restore(store, data, home)
478
+ raise
479
+ return txid
480
+
481
+
482
+ def restore(store, data, home):
483
+ """Return every guide to its pre-transaction content, refusing over later edits."""
484
+ state = load_state(store)
485
+ if state != data["before"] and state != data["after"]:
486
+ raise CrowError("recovery_conflict", "Estado de guias mudou após a transação; recuperação recusada.")
487
+ backups = store.root / "guide-backups" / data["id"]
488
+ operations = []
489
+ for item in reversed(data["items"]):
490
+ path = protect(Path(item["path"]), home)
491
+ now_data = read_regular(path)
492
+ now_sha = sha(now_data)
493
+ if now_sha == item["old_sha"]:
494
+ continue
495
+ old = None
496
+ if item["old_sha"] is not None:
497
+ old = read_regular(checked(backups / item["backup"]))
498
+ if sha(old) != item["old_sha"]:
499
+ raise CrowError("recovery_conflict", "Backup do guia ausente ou alterado; recuperação recusada.")
500
+ if now_sha == item["new_sha"]:
501
+ operations.append((path, old, item))
502
+ elif item["kind"] == "toml" and now_data is not None and toml_block(now_data) == item["new_block"]:
503
+ # Codex rewrote other parts of config.toml: undo only our block.
504
+ operations.append((path, toml_compose(now_data, item["new_block"], item["old_block"]), item))
505
+ else:
506
+ raise CrowError("recovery_conflict", "Guia de " + item["agent"] + " mudou após a transação; recuperação não sobrescreverá alterações.")
507
+ data["phase"] = "restoring"
508
+ atomic_json(store.root / "transactions" / (data["id"] + ".json"), data)
509
+ for path, content, item in operations:
510
+ if content is None:
511
+ if os.path.lexists(path):
512
+ path.unlink()
513
+ else:
514
+ for directory in reversed(item["removed_dirs"]):
515
+ if not os.path.lexists(directory):
516
+ os.mkdir(directory, 0o755)
517
+ write_atomic(path, content, item["mode"])
518
+ for item in data["items"]:
519
+ remove_empty(item["created_dirs"])
520
+ atomic_json(state_path(store), data["before"])
521
+ data["phase"] = "rolled_back"
522
+ atomic_json(store.root / "transactions" / (data["id"] + ".json"), data)
523
+
524
+
525
+ def preserved_original(store, record):
526
+ """Bytes that --replace displaced for this guide, verified against the state; None if nothing was displaced."""
527
+ kept = record.get("preserved")
528
+ if not kept:
529
+ return None
530
+ data = read_regular(checked(store.root / "guide-backups" / kept["backup"]))
531
+ if sha(data) != kept["sha256"]:
532
+ raise CrowError("recovery_conflict", "Backup do conteúdo original substituído por --replace está ausente ou alterado; nada removido.")
533
+ return data
534
+
535
+
536
+ def remove_empty(directories):
537
+ """Remove directories this CLI created, deepest first, only while empty."""
538
+ for directory in directories:
539
+ try:
540
+ os.rmdir(checked(directory))
541
+ except OSError:
542
+ pass # Not empty or already gone: never remove foreign content.
543
+
544
+
545
+ def rollback(store, data, home, recovery=False):
546
+ if not recovery and load_state(store) != data["after"]:
547
+ raise CrowError("rollback_conflict", "Há mudanças posteriores nos guias; rollback antigo recusado.")
548
+ restore(store, data, home)
549
+
550
+
551
+ # --- status ---------------------------------------------------------------------
552
+
553
+ def status(store, home, agents):
554
+ state = load_state(store)
555
+ rows = []
556
+ for agent in agents:
557
+ if agent not in MECHANISMS:
558
+ continue
559
+ mech = MECHANISMS[agent]
560
+ record = state["guides"].get(agent)
561
+ row = {"agent": agent, "path": str(home / mech["path"]), "mechanism": mech["kind"],
562
+ "revision": record["revision"] if record else None, "activation_verified": False}
563
+ try:
564
+ path, data, managed = current(agent, home)
565
+ parsed = parse_toml(decode(data)) if mech["kind"] == "toml" and data is not None else {}
566
+ if record is None:
567
+ row["status"] = "unmanaged" if managed is not None or TOML_KEY in parsed else "absent"
568
+ elif managed is None:
569
+ row["status"] = "missing"
570
+ elif sha(managed) != record["sha256"]:
571
+ row["status"] = "modified"
572
+ elif mech["kind"] == "toml" and parsed.get(TOML_KEY) != parse_toml(managed).get(TOML_KEY):
573
+ row["status"] = "modified"
574
+ row["note"] = "Bloco gerenciado não está mais no nível superior do config.toml."
575
+ elif managed_skills(agent, home, store) != record["skills"]:
576
+ row["status"] = "outdated"
577
+ row["note"] = "Skills instaladas mudaram; execute crow-skills guide install para regenerar."
578
+ else:
579
+ row["status"] = "installed"
580
+ except CrowError as exc:
581
+ row["status"] = "unsafe"
582
+ row["note"] = str(exc)
583
+ rows.append(row)
584
+ return rows
585
+