@andespindola/crow-skills 0.3.0 → 0.5.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/crow_skills/__init__.py +1 -1
- package/crow_skills/cli.py +112 -4
- package/crow_skills/integrations.py +520 -0
- package/crow_skills/progress.py +667 -0
- package/docs/cli.md +61 -3
- package/package.json +1 -1
- package/release.json +36 -11
- package/skills/crow-behaviour/SKILL.md +27 -18
- package/skills/crow-behaviour/agents/openai.yaml +1 -1
- package/skills/crow-behaviour/references/global-cli-config.md +87 -0
- 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,520 @@
|
|
|
1
|
+
"""Host UI integrations that show the active crow-behaviour run.
|
|
2
|
+
|
|
3
|
+
Each integration uses an extension point the host really exposes and that was
|
|
4
|
+
checked on the host versions listed in docs/cli.md:
|
|
5
|
+
|
|
6
|
+
- claude-statusline: the ``statusLine`` command in ~/.claude/settings.json. A
|
|
7
|
+
status line that already exists is chained (it keeps printing first), never
|
|
8
|
+
replaced.
|
|
9
|
+
- codex-statusline: ``[tui].status_line`` in ~/.codex/config.toml, which only
|
|
10
|
+
accepts Codex's built-in items. It shows Codex's own plan progress
|
|
11
|
+
("task-progress") and token usage; it cannot run a command. Applied only
|
|
12
|
+
when the user has not set a status line.
|
|
13
|
+
- tmux-status: a segment appended to ``status-right`` from a sourced snippet,
|
|
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``.
|
|
19
|
+
|
|
20
|
+
Ownership: one key (claude), one marked block (codex, tmux.conf) and two whole
|
|
21
|
+
files under ~/.config/crow-skills and the CLI state. Every edited file is
|
|
22
|
+
backed up first, written atomically, never through a symlink or hard link,
|
|
23
|
+
and restored on uninstall only if it still holds what this CLI wrote. These
|
|
24
|
+
are best-effort steps after the skills transaction: a refusal is reported in
|
|
25
|
+
``skipped`` and never undoes the skills.
|
|
26
|
+
"""
|
|
27
|
+
from contextlib import nullcontext
|
|
28
|
+
from copy import deepcopy
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
import re
|
|
34
|
+
import shlex
|
|
35
|
+
import shutil
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
from .guide import protect, read_regular, sha, tomllib, write_atomic
|
|
39
|
+
from .source import CrowError
|
|
40
|
+
from .storage import atomic_json, checked, private_dir, read_json
|
|
41
|
+
|
|
42
|
+
NAMES = ("claude-statusline", "codex-statusline", "tmux-status")
|
|
43
|
+
OWNERS = {"claude-statusline": ("claude",), "codex-statusline": ("codex",),
|
|
44
|
+
"tmux-status": ("claude", "codex", "gemini", "devin")}
|
|
45
|
+
BEGIN = "# >>> crow-skills integrations (gerenciado; use crow-skills integrations uninstall) >>>"
|
|
46
|
+
END = "# <<< crow-skills integrations <<<"
|
|
47
|
+
CODEX_ITEMS = ["model-with-reasoning", "context-remaining", "current-dir", "task-progress", "used-tokens"]
|
|
48
|
+
SNIPPET_HEADER = "# Gerenciado por crow-skills (integração tmux). Não edite; use crow-skills integrations."
|
|
49
|
+
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.",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def tmux_binary():
|
|
58
|
+
return shutil.which("tmux")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def python_binary():
|
|
62
|
+
return shutil.which("python3") or sys.executable
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def now():
|
|
66
|
+
return datetime.now(timezone.utc).isoformat()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def root(store):
|
|
70
|
+
return store.root / "integrations"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def state_path(store):
|
|
74
|
+
return root(store) / "state.json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_state(store):
|
|
78
|
+
path = checked(state_path(store))
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return {"schema_version": 1, "integrations": {}}
|
|
81
|
+
data = read_json(path)
|
|
82
|
+
if data.get("schema_version") != 1 or not isinstance(data.get("integrations"), dict):
|
|
83
|
+
raise CrowError("invalid_state", "Estado de integrações inválido; nada alterado.")
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def script_path(store):
|
|
88
|
+
return root(store) / "crow_progress.py"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def script_source():
|
|
92
|
+
return (Path(__file__).resolve().parent / "progress.py").read_bytes()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def quoted_ok(*paths):
|
|
96
|
+
return all(not re.search(r"[\"'\\$`\n]", str(p)) for p in paths)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def backup(store, name, data):
|
|
100
|
+
if data is None:
|
|
101
|
+
return None
|
|
102
|
+
directory = private_dir(root(store) / "backups")
|
|
103
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
104
|
+
path = directory / (stamp + "-" + name + ".bak")
|
|
105
|
+
write_atomic(path, data, 0o600)
|
|
106
|
+
return path.name
|
|
107
|
+
|
|
108
|
+
|
|
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
|
+
def write(path, data, old):
|
|
121
|
+
mode = (path.lstat().st_mode & 0o777) if old is not None else 0o644
|
|
122
|
+
if not path.parent.exists():
|
|
123
|
+
path.parent.mkdir(parents=True, mode=0o755)
|
|
124
|
+
write_atomic(path, data, mode)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --- claude ---------------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def claude_sync(store, home, record, desired, python, dry):
|
|
130
|
+
path = protect(home / ".claude" / "settings.json", home)
|
|
131
|
+
old = read_regular(path)
|
|
132
|
+
try:
|
|
133
|
+
settings = json.loads(old.decode("utf-8")) if old is not None and old.strip() else {}
|
|
134
|
+
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."
|
|
138
|
+
current = settings.get("statusLine")
|
|
139
|
+
chain_file = root(store) / "claude-statusline-chain.json"
|
|
140
|
+
if not desired:
|
|
141
|
+
if not record:
|
|
142
|
+
return None, "unchanged", None
|
|
143
|
+
if current != record["value"]:
|
|
144
|
+
if not dry and chain_file.exists():
|
|
145
|
+
chain_file.unlink()
|
|
146
|
+
return None, "skip", "statusLine foi alterado depois da instalação; mantido como está e removido do controle do crow-skills."
|
|
147
|
+
if record.get("previous") is not None:
|
|
148
|
+
settings["statusLine"] = record["previous"]
|
|
149
|
+
else:
|
|
150
|
+
settings.pop("statusLine", None)
|
|
151
|
+
if dry:
|
|
152
|
+
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)
|
|
162
|
+
if chain_file.exists():
|
|
163
|
+
chain_file.unlink()
|
|
164
|
+
return None, "remove", None
|
|
165
|
+
script = script_path(store)
|
|
166
|
+
if not quoted_ok(python, script, chain_file):
|
|
167
|
+
return record, "skip", "Caminho do Python ou do estado contém aspas/variáveis; status line não configurada."
|
|
168
|
+
previous = record.get("previous") if record else None
|
|
169
|
+
if record and current != record["value"]:
|
|
170
|
+
return record, "skip", "statusLine foi alterado depois da instalação; não sobrescrito. Rode crow-skills integrations uninstall para desfazer o controle."
|
|
171
|
+
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)):
|
|
173
|
+
return None, "skip", "statusLine existente não é do tipo command; não encadeado nem substituído."
|
|
174
|
+
previous = current
|
|
175
|
+
command = shlex.quote(str(python)) + " " + shlex.quote(str(script)) + " statusline"
|
|
176
|
+
if previous is not None:
|
|
177
|
+
command += " --chain-file " + shlex.quote(str(chain_file))
|
|
178
|
+
# 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}
|
|
180
|
+
value = {"type": "command", "command": command, **value}
|
|
181
|
+
if record and current == value:
|
|
182
|
+
return record, "unchanged", None
|
|
183
|
+
settings["statusLine"] = value
|
|
184
|
+
action = "update" if record else ("chain" if previous is not None else "install")
|
|
185
|
+
new_record = {"path": str(path), "value": value, "previous": previous, "installed_at": now(),
|
|
186
|
+
"created_file": record.get("created_file", False) if record else old is None}
|
|
187
|
+
if dry:
|
|
188
|
+
return new_record, action, None
|
|
189
|
+
if previous is not None:
|
|
190
|
+
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)
|
|
201
|
+
return new_record, action, None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- codex ---------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def marked(text):
|
|
207
|
+
begins = [m.start() for m in re.finditer(r"(?m)^" + re.escape(BEGIN) + r"\n", text)]
|
|
208
|
+
ends = [m.end() for m in re.finditer(r"(?m)^" + re.escape(END) + r"\n", text)]
|
|
209
|
+
if not begins and not ends:
|
|
210
|
+
return None
|
|
211
|
+
if len(begins) != 1 or len(ends) != 1 or ends[0] <= begins[0]:
|
|
212
|
+
raise CrowError("integration_corrupted", "Marcadores de integração crow-skills incompletos ou duplicados; revise manualmente.")
|
|
213
|
+
return begins[0], ends[0]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def codex_block(created_table):
|
|
217
|
+
items = "status_line = [" + ", ".join('"' + i + '"' for i in CODEX_ITEMS) + "]\n"
|
|
218
|
+
return BEGIN + "\n" + ("[tui]\n" if created_table else "") + items + END + "\n"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def codex_removal(text, span, record):
|
|
222
|
+
"""Text without the managed block, or None when removing it would change anything else.
|
|
223
|
+
|
|
224
|
+
The block must be exactly what install wrote, and the parsed result must
|
|
225
|
+
equal the current parse minus tui.status_line (minus an emptied [tui]
|
|
226
|
+
that install created). When install created [tui] and keys were added
|
|
227
|
+
below the block since, the header stays so they keep their table.
|
|
228
|
+
"""
|
|
229
|
+
created_table = record.get("created_table", False)
|
|
230
|
+
if text[span[0]:span[1]] != codex_block(created_table):
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
current = tomllib.loads(text)
|
|
234
|
+
except tomllib.TOMLDecodeError:
|
|
235
|
+
return None
|
|
236
|
+
expected = deepcopy(current)
|
|
237
|
+
tui = expected.get("tui")
|
|
238
|
+
if not isinstance(tui, dict) or tui.get("status_line") != CODEX_ITEMS:
|
|
239
|
+
return None
|
|
240
|
+
del tui["status_line"]
|
|
241
|
+
attempts = []
|
|
242
|
+
if created_table and not tui:
|
|
243
|
+
del expected["tui"]
|
|
244
|
+
rest = text[:span[0]] + text[span[1]:]
|
|
245
|
+
if record.get("added_newline") and rest.endswith("\n") and span[1] == len(text):
|
|
246
|
+
rest = rest[:-1]
|
|
247
|
+
attempts.append(rest)
|
|
248
|
+
elif created_table:
|
|
249
|
+
attempts.append(text[:span[0]] + "[tui]\n" + text[span[1]:])
|
|
250
|
+
else:
|
|
251
|
+
attempts.append(text[:span[0]] + text[span[1]:])
|
|
252
|
+
for result in attempts:
|
|
253
|
+
try:
|
|
254
|
+
if tomllib.loads(result) == expected:
|
|
255
|
+
return result
|
|
256
|
+
except tomllib.TOMLDecodeError:
|
|
257
|
+
continue
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def codex_sync(store, home, record, desired, python, dry):
|
|
262
|
+
del python
|
|
263
|
+
if tomllib is None:
|
|
264
|
+
return record, "skip", "Python 3.11+ (tomllib) é necessário para editar config.toml com verificação."
|
|
265
|
+
directory = home / ".codex"
|
|
266
|
+
if not directory.is_dir():
|
|
267
|
+
return record, "skip", "Codex sem ~/.codex; nada alterado."
|
|
268
|
+
path = protect(directory / "config.toml", home)
|
|
269
|
+
old = read_regular(path)
|
|
270
|
+
try:
|
|
271
|
+
text = old.decode("utf-8") if old is not None else ""
|
|
272
|
+
except UnicodeDecodeError:
|
|
273
|
+
return record, "skip", "config.toml não é UTF-8; nada alterado."
|
|
274
|
+
span = marked(text)
|
|
275
|
+
stripped = text[:span[0]] + text[span[1]:] if span else text
|
|
276
|
+
try:
|
|
277
|
+
base = tomllib.loads(stripped)
|
|
278
|
+
except tomllib.TOMLDecodeError:
|
|
279
|
+
return record, "skip", "config.toml não é TOML válido; nada alterado."
|
|
280
|
+
if not desired:
|
|
281
|
+
if not record:
|
|
282
|
+
return None, "unchanged", None
|
|
283
|
+
if not span:
|
|
284
|
+
return None, "skip", "Bloco de integração ausente em config.toml; nada a remover."
|
|
285
|
+
result = codex_removal(text, span, record)
|
|
286
|
+
if result is None:
|
|
287
|
+
return None, "skip", ("Bloco do crow-skills em config.toml foi editado ou a remoção mudaria outras chaves "
|
|
288
|
+
"(por exemplo, chaves acrescentadas a [tui] depois do bloco); nada alterado, "
|
|
289
|
+
"remova o bloco manualmente e o controle do crow-skills foi encerrado.")
|
|
290
|
+
if dry:
|
|
291
|
+
return None, "remove", None
|
|
292
|
+
backup(store, "codex-config", old)
|
|
293
|
+
if record.get("created_file") and not result.strip():
|
|
294
|
+
path.unlink()
|
|
295
|
+
else:
|
|
296
|
+
write(path, result.encode("utf-8"), old)
|
|
297
|
+
return None, "remove", None
|
|
298
|
+
tui = base.get("tui")
|
|
299
|
+
added_newline = record.get("added_newline", False) if record else False
|
|
300
|
+
if span and record:
|
|
301
|
+
current_block = text[span[0]:span[1]]
|
|
302
|
+
created_table = record.get("created_table", False)
|
|
303
|
+
block = codex_block(created_table)
|
|
304
|
+
if current_block == block:
|
|
305
|
+
return record, "unchanged", None
|
|
306
|
+
result = text[:span[0]] + block + text[span[1]:]
|
|
307
|
+
elif span:
|
|
308
|
+
return record, "skip", "Bloco de integração sem registro no estado; revise config.toml manualmente."
|
|
309
|
+
elif tui is None:
|
|
310
|
+
created_table = True
|
|
311
|
+
block = codex_block(True)
|
|
312
|
+
added_newline = bool(stripped) and not stripped.endswith("\n")
|
|
313
|
+
result = stripped + ("\n" if added_newline else "") + block
|
|
314
|
+
elif isinstance(tui, dict) and "status_line" not in tui:
|
|
315
|
+
headers = list(re.finditer(r"(?m)^\[tui\][ \t]*(?:#.*)?\n", stripped))
|
|
316
|
+
if len(headers) != 1:
|
|
317
|
+
return None, "skip", "Tabela [tui] definida de forma não editável com segurança; status line não alterada."
|
|
318
|
+
created_table = False
|
|
319
|
+
block = codex_block(False)
|
|
320
|
+
cut = headers[0].end()
|
|
321
|
+
result = stripped[:cut] + block + stripped[cut:]
|
|
322
|
+
else:
|
|
323
|
+
return None, "skip", "tui.status_line já definido pelo usuário; não sobrescrito."
|
|
324
|
+
expected = deepcopy(base)
|
|
325
|
+
expected.setdefault("tui", {})["status_line"] = CODEX_ITEMS
|
|
326
|
+
try:
|
|
327
|
+
if tomllib.loads(result) != expected:
|
|
328
|
+
raise ValueError("diff")
|
|
329
|
+
except (tomllib.TOMLDecodeError, ValueError):
|
|
330
|
+
return record, "skip", "A edição do config.toml não preservaria o restante do arquivo; nada alterado."
|
|
331
|
+
action = "update" if record else "install"
|
|
332
|
+
new_record = {"path": str(path), "created_table": created_table, "added_newline": added_newline, "installed_at": now(),
|
|
333
|
+
"created_file": record.get("created_file", False) if record else old is None}
|
|
334
|
+
if dry:
|
|
335
|
+
return new_record, action, None
|
|
336
|
+
new_record["backup"] = backup(store, "codex-config", old)
|
|
337
|
+
write(path, result.encode("utf-8"), old)
|
|
338
|
+
return new_record, action, None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# --- tmux --------------------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def tmux_conf(home):
|
|
344
|
+
candidates = [home / ".tmux.conf"]
|
|
345
|
+
xdg = os.environ.get("XDG_CONFIG_HOME") if home == Path.home().resolve() else None
|
|
346
|
+
candidates.append((Path(xdg).expanduser() if xdg else home / ".config") / "tmux" / "tmux.conf")
|
|
347
|
+
for candidate in candidates:
|
|
348
|
+
if os.path.lexists(candidate):
|
|
349
|
+
return Path(os.path.abspath(candidate))
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def snippet_path(home):
|
|
354
|
+
return home / ".config" / "crow-skills" / "tmux.conf"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def snippet_content(python, script):
|
|
358
|
+
# #{q:...} makes tmux shell-escape the pane path: a directory named with $(...) or
|
|
359
|
+
# backticks must reach the script as data, never run. python/script are checked by
|
|
360
|
+
# quoted_ok (no quotes, $, backticks or backslashes) before this is written.
|
|
361
|
+
command = '"' + str(python) + '" "' + str(script) + '" tmux --cwd #{q:pane_current_path}'
|
|
362
|
+
# Guard on status-right itself, not on a server option: a reload of the user's
|
|
363
|
+
# tmux.conf resets status-right, and the segment must come back after it.
|
|
364
|
+
return (SNIPPET_HEADER + "\n"
|
|
365
|
+
"if-shell -F '#{m:*" + script.name + "*,#{status-right}}' '' {\n"
|
|
366
|
+
" set -ag status-right ' #(" + command + ")'\n"
|
|
367
|
+
"}\n")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def symlinked(path):
|
|
371
|
+
path = Path(os.path.abspath(path))
|
|
372
|
+
return any(p.is_symlink() for p in (path, *path.parents))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def tmux_sync(store, home, record, desired, python, dry):
|
|
376
|
+
snippet = snippet_path(home)
|
|
377
|
+
if not desired:
|
|
378
|
+
if not record:
|
|
379
|
+
return None, "unchanged", None
|
|
380
|
+
if dry:
|
|
381
|
+
return None, "remove", None
|
|
382
|
+
notes = []
|
|
383
|
+
conf = Path(record["conf"]) if record.get("conf") else None
|
|
384
|
+
if conf and record.get("block"):
|
|
385
|
+
if symlinked(conf):
|
|
386
|
+
notes.append("tmux.conf virou symlink; bloco não removido.")
|
|
387
|
+
else:
|
|
388
|
+
old = read_regular(conf)
|
|
389
|
+
text = old.decode("utf-8", "replace") if old is not None else ""
|
|
390
|
+
span = marked(text) if old is not None else None
|
|
391
|
+
if span:
|
|
392
|
+
rest = text[:span[0]] + text[span[1]:]
|
|
393
|
+
if record.get("added_newline") and rest.endswith("\n"):
|
|
394
|
+
rest = rest[:-1]
|
|
395
|
+
backup(store, "tmux-conf", old)
|
|
396
|
+
write(conf, rest.encode("utf-8"), old)
|
|
397
|
+
if os.path.lexists(snippet) and not symlinked(snippet):
|
|
398
|
+
data = read_regular(snippet)
|
|
399
|
+
if data is not None and data.decode("utf-8", "replace").startswith(SNIPPET_HEADER):
|
|
400
|
+
snippet.unlink()
|
|
401
|
+
try:
|
|
402
|
+
snippet.parent.rmdir()
|
|
403
|
+
except OSError:
|
|
404
|
+
pass
|
|
405
|
+
notes.append("O segmento sai da sessão atual quando o status-right é redefinido (tmux source-file de um tmux.conf que o define) "
|
|
406
|
+
"ou quando o servidor tmux reinicia.")
|
|
407
|
+
return None, "remove", " ".join(notes)
|
|
408
|
+
if not tmux_binary():
|
|
409
|
+
return record, "skip", "tmux não encontrado no PATH; segmento não instalado."
|
|
410
|
+
conf = tmux_conf(home)
|
|
411
|
+
if conf is None:
|
|
412
|
+
return record, "skip", "tmux sem arquivo de configuração (~/.tmux.conf ou ~/.config/tmux/tmux.conf); nada alterado."
|
|
413
|
+
script = script_path(store)
|
|
414
|
+
if not quoted_ok(python, script, snippet):
|
|
415
|
+
return record, "skip", "Caminho do Python ou do estado contém aspas/variáveis; segmento não instalado."
|
|
416
|
+
content = snippet_content(python, script).encode("utf-8")
|
|
417
|
+
line = "source-file -q '" + str(snippet) + "'"
|
|
418
|
+
block = BEGIN + "\n" + line + "\n" + END + "\n"
|
|
419
|
+
if symlinked(snippet):
|
|
420
|
+
return record, "skip", "~/.config/crow-skills é symlink; segmento não instalado."
|
|
421
|
+
manual = symlinked(conf)
|
|
422
|
+
old = None if manual else read_regular(conf)
|
|
423
|
+
text = old.decode("utf-8", "replace") if old is not None else ""
|
|
424
|
+
span = marked(text) if old is not None else None
|
|
425
|
+
current_snippet = read_regular(snippet) if os.path.lexists(snippet) else None
|
|
426
|
+
if current_snippet is not None and not current_snippet.decode("utf-8", "replace").startswith(SNIPPET_HEADER):
|
|
427
|
+
return record, "skip", str(snippet) + " existe e não é gerenciado; nada alterado."
|
|
428
|
+
in_place = manual or (span is not None and text[span[0]:span[1]] == block)
|
|
429
|
+
if record and in_place and current_snippet == content:
|
|
430
|
+
return record, "unchanged", ("tmux.conf é symlink: adicione manualmente a linha " + line) if manual else None
|
|
431
|
+
action = "update" if record else "install"
|
|
432
|
+
new_record = {"conf": str(conf), "snippet": str(snippet), "block": not manual, "installed_at": now(),
|
|
433
|
+
"added_newline": record.get("added_newline", False) if record else False}
|
|
434
|
+
note = None
|
|
435
|
+
if manual:
|
|
436
|
+
note = "tmux.conf é symlink (não é gravado através de links); snippet instalado. Adicione ao seu tmux.conf: " + line
|
|
437
|
+
if dry:
|
|
438
|
+
return new_record, action, note
|
|
439
|
+
write(snippet, content, current_snippet)
|
|
440
|
+
if not manual and not (span and text[span[0]:span[1]] == block):
|
|
441
|
+
if span:
|
|
442
|
+
result = text[:span[0]] + block + text[span[1]:]
|
|
443
|
+
else:
|
|
444
|
+
new_record["added_newline"] = bool(text) and not text.endswith("\n")
|
|
445
|
+
result = text + ("\n" if new_record["added_newline"] else "") + block
|
|
446
|
+
new_record["backup"] = backup(store, "tmux-conf", old)
|
|
447
|
+
write(conf, result.encode("utf-8"), old)
|
|
448
|
+
return new_record, action, note
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
SYNC = {"claude-statusline": claude_sync, "codex-statusline": codex_sync, "tmux-status": tmux_sync}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# --- orchestration -------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
def sync(store, home, wanted, dry_run=False, python=None):
|
|
457
|
+
"""Bring every integration in line with the agents that have crow-behaviour.
|
|
458
|
+
|
|
459
|
+
wanted: agents that will have crow-behaviour managed after the command.
|
|
460
|
+
Returns a report; never raises for a single integration's refusal.
|
|
461
|
+
"""
|
|
462
|
+
python = python or python_binary()
|
|
463
|
+
report = {"changes": [], "skipped": [], "unsupported": [{"agent": a, "reason": r} for a, r in UNSUPPORTED.items() if a in wanted]}
|
|
464
|
+
try:
|
|
465
|
+
state = load_state(store)
|
|
466
|
+
except CrowError as exc:
|
|
467
|
+
report["skipped"].append({"integration": "*", "reason": str(exc)})
|
|
468
|
+
return report
|
|
469
|
+
after = {"schema_version": 1, "integrations": dict(state["integrations"])}
|
|
470
|
+
with (nullcontext() if dry_run else store.lock()):
|
|
471
|
+
if not dry_run and any(set(OWNERS[n]) & set(wanted) for n in NAMES):
|
|
472
|
+
script = script_path(store)
|
|
473
|
+
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:
|
|
478
|
+
desired = bool(set(OWNERS[name]) & set(wanted))
|
|
479
|
+
record = state["integrations"].get(name)
|
|
480
|
+
try:
|
|
481
|
+
new_record, action, note = SYNC[name](store, home, record, desired, python, dry_run)
|
|
482
|
+
except (CrowError, OSError, ValueError) as exc:
|
|
483
|
+
report["skipped"].append({"integration": name, "reason": str(exc) or "falha de leitura/gravação"})
|
|
484
|
+
continue
|
|
485
|
+
if action == "skip":
|
|
486
|
+
report["skipped"].append({"integration": name, "reason": note})
|
|
487
|
+
elif action != "unchanged" or note:
|
|
488
|
+
change = {"integration": name, "action": action}
|
|
489
|
+
if note:
|
|
490
|
+
change["note"] = note
|
|
491
|
+
report["changes"].append(change)
|
|
492
|
+
if new_record is None:
|
|
493
|
+
after["integrations"].pop(name, None)
|
|
494
|
+
else:
|
|
495
|
+
after["integrations"][name] = new_record
|
|
496
|
+
if not dry_run:
|
|
497
|
+
if after != state:
|
|
498
|
+
atomic_json(state_path(store), after)
|
|
499
|
+
if not after["integrations"]:
|
|
500
|
+
script = script_path(store)
|
|
501
|
+
if script.exists():
|
|
502
|
+
script.unlink()
|
|
503
|
+
report["dry_run"] = dry_run
|
|
504
|
+
return report
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def status(store, home):
|
|
508
|
+
state = load_state(store)
|
|
509
|
+
rows = []
|
|
510
|
+
for name in NAMES:
|
|
511
|
+
record = state["integrations"].get(name)
|
|
512
|
+
row = {"integration": name, "agents": list(OWNERS[name]), "status": "installed" if record else "absent"}
|
|
513
|
+
if record:
|
|
514
|
+
row["path"] = record.get("path") or record.get("conf")
|
|
515
|
+
if name == "tmux-status" and not record.get("block"):
|
|
516
|
+
row["note"] = "tmux.conf é symlink; linha source-file adicionada manualmente pelo usuário."
|
|
517
|
+
rows.append(row)
|
|
518
|
+
for agent, reason in UNSUPPORTED.items():
|
|
519
|
+
rows.append({"integration": agent + "-statusline", "agents": [agent], "status": "unsupported", "note": reason})
|
|
520
|
+
return rows
|