@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.
@@ -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