@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.
- package/README.md +7 -3
- package/catalog.json +1 -1
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/cli.py +261 -8
- package/crow_skills/guide.py +585 -0
- package/crow_skills/integrations.py +520 -0
- package/crow_skills/progress.py +667 -0
- package/crow_skills/storage.py +6 -0
- package/docs/cli-security.md +2 -0
- package/docs/cli.md +104 -4
- package/package.json +1 -1
- package/release.json +39 -14
- package/skills/crow-behaviour/SKILL.md +24 -19
- package/skills/crow-behaviour/agents/openai.yaml +1 -1
- package/skills/crow-behaviour/references/orchestration.md +19 -9
- package/skills/crow-behaviour/references/progress-ledger.md +83 -0
- package/skills/crow-behaviour/references/quality-patterns.md +22 -2
- package/skills/crow-behaviour/scripts/crow_ledger.py +279 -0
|
@@ -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
|
+
|