@andespindola/crow-skills 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Skills com escopo explícito, conhecimento especializado preservado e fluxos comuns reutilizáveis.
4
4
 
5
- **Versão 0.7.1** ([mudanças](CHANGELOG.md)): as skills especialistas recebem as correções da revalidação no Codex: injeção citada por inteiro após varrer o alvo, conflito com padrão adotado e reversão, contrato e documento de outro dono só como proposta sem pedido explícito, descartados completos e handoff que diz se a skill está instalada. A 0.7.0 tornou o Uniflow agente de instalação (`--agents uniflow`); a 0.6.1 passou a mostrar o progresso da crow-behaviour na própria conversa; a 0.6.0 trouxe as regras de entrega das 32 skills especialistas, a partir da [validação no Codex](docs/evaluations/2026-10-26-codex.md).
5
+ **Versão 0.8.0** ([mudanças](CHANGELOG.md)): `crow-skills mode on|off` liga e desliga a crow-behaviour em todas as sessões e CLIs (Claude Code, Codex, Gemini CLI, Antigravity, Devin) a partir do próximo prompt, com `/crow on|off` dentro da sessão e o indicador `▸▸ crow on` na status line; a crow-behaviour mostra o progresso como a lista de agentes do host (`● main`, `○ dono atividade`); `crow-skills events` publica um barramento de eventos dos hooks para terminais; `crow-skills autoupdate on` atualiza o CLI e as skills gerenciadas todo dia e no login. A 0.7.1 trouxe as correções da revalidação no Codex às skills especialistas; a 0.7.0 tornou o Uniflow agente de instalação (`--agents uniflow`); a 0.6.1 passou a mostrar o progresso da crow-behaviour na própria conversa; a 0.6.0 trouxe as regras de entrega das 32 skills especialistas, a partir da [validação no Codex](docs/evaluations/2026-10-26-codex.md).
6
6
 
7
7
  **Reconstrução em andamento.** A primeira versão com 32 contratos genéricos não atende à preservação de capacidades solicitada. Não use esse número nem o mapeamento por categoria como prova de substituição das originais. A revisão passa a abranger cada definição e seus recursos, modos e dependências, sem número fixo de skills ou limite artificial de linhas. Veja [critérios e progresso](docs/reconstruction.md).
8
8
 
@@ -14,7 +14,7 @@ Skills com escopo explícito, conhecimento especializado preservado e fluxos com
14
14
  4. Entregue o artefato com alvo/revisão, evidência e pendências. Desconhecido não significa aprovado.
15
15
  5. Carregue outra skill só quando houver decisão distinta a tomar. Handoffs são opcionais, não dependências ocultas.
16
16
 
17
- Para adotar uma atuação mais coordenada em qualquer domínio, use `crow-behaviour`: tarefas substantivas recebem plano, delegação suportada, verificação e síntese; conversa e ajustes triviais continuam diretos. A skill adapta o comportamento às ferramentas reais, inclusive execução sequencial, sem depender do Workflow do Claude. Em tarefa substantiva ela mantém um ledger de progresso (`.crow/runs/`), mostra um resumo por fase no chat e, dentro do tmux, um painel ao vivo com `crow-skills progress --watch`. Ela não muda o modelo/effort nem força carregamento automático pelo host. [Escopo e evidência](analysis/crow-behaviour.md).
17
+ Para adotar uma atuação mais coordenada em qualquer domínio, use `crow-behaviour`: tarefas substantivas recebem plano, delegação suportada, verificação e síntese; conversa e ajustes triviais continuam diretos. A skill adapta o comportamento às ferramentas reais, inclusive execução sequencial, sem depender do Workflow do Claude. Em tarefa substantiva ela mantém um ledger de progresso (`.crow/runs/`) e mostra na conversa o bloco de processos no formato da lista de agentes do host (`● main …` e `○ dono atividade` por unidade em andamento); `crow-skills progress --watch` e as status lines mostram o mesmo. `crow-skills mode on|off` (ou `/crow on|off`) a liga ou desliga em todos os CLIs. Ela não muda o modelo/effort. [Escopo e evidência](analysis/crow-behaviour.md).
18
18
 
19
19
  Exemplos:
20
20
  - Plataforma nova: `crow-platform` coordena; `crow-discovery` e `crow-spec` definem o que falta; as especialistas implementam/verificam por fatia.
@@ -27,10 +27,11 @@ Exemplos:
27
27
 
28
28
  ## Instalação e portabilidade
29
29
 
30
- O pacote de cada skill é somente sua pasta em `skills/`. O [CLI crow-skills](docs/cli.md) instala cópias completas e versionadas em agentes/escopos explícitos, com preview, saída JSON, detecção de conflitos e recuperação. Requer Python 3.10+; Git para fontes em clone, Node 18+ para o launcher npm; mutações em macOS/Linux. A distribuição npm privada `@andespindola/crow-skills` inclui um snapshot completo das skills, sem clone ou hooks que as instalem automaticamente. No [Uniflow](docs/cli.md#uniflow), `--agents uniflow --global` grava as skills no store dele (`~/.config/uniflow/skills`), sem tocar no resto da configuração. Em `--global`, instalar a crow-behaviour também grava o guia aditivo do agente (claude, codex, gemini, devin) para usar as skills; `--no-guide` desliga. As integrações de interface (status line do Claude Code e do Codex, segmento tmux) seguem o mesmo fluxo, preservando a configuração existente; `--no-integrations` desliga.
30
+ O pacote de cada skill é somente sua pasta em `skills/`. O [CLI crow-skills](docs/cli.md) instala cópias completas e versionadas em agentes/escopos explícitos, com preview, saída JSON, detecção de conflitos e recuperação. Requer Python 3.10+; Git para fontes em clone, Node 18+ para o launcher npm; mutações em macOS/Linux. A distribuição npm privada `@andespindola/crow-skills` inclui um snapshot completo das skills, sem clone ou hooks que as instalem automaticamente. No [Uniflow](docs/cli.md#uniflow), `--agents uniflow --global` grava as skills no store dele (`~/.config/uniflow/skills`), sem tocar no resto da configuração. Em `--global`, instalar a crow-behaviour também grava o guia aditivo do agente (claude, codex, gemini, devin) para usar as skills; `--no-guide` desliga. As integrações de interface (status lines do Claude Code, do Codex e do Antigravity, hooks do modo crow em cada CLI, comando `/crow`, segmento tmux) seguem o mesmo fluxo, preservando a configuração existente; `--no-integrations` desliga. O [barramento de eventos](docs/events.md) e a atualização automática (`crow-skills autoupdate on`) estão no [CLI](docs/cli.md).
31
31
 
32
32
  ```sh
33
- npm install -g @andespindola/crow-skills@0.7.1
33
+ npm install -g @andespindola/crow-skills@0.8.0
34
+ crow-skills mode
34
35
  crow-skills progress
35
36
  ./bin/crow-skills list
36
37
  ./bin/crow-skills install crow-behaviour --agents claude,codex --global --dry-run
@@ -40,7 +41,7 @@ crow-skills progress
40
41
 
41
42
  Também é possível copiar/referenciar manualmente as pastas escolhidas no diretório suportado pelo host. Para symlink manual, mantenha o checkout no mesmo caminho. O CLI v0.1 só instala cópias; não sobrescreve conflitos ou edições sem opção explícita e backup.
42
43
 
43
- Os nomes têm prefixo `crow-` para coexistir com o catálogo anterior. Não há instalação global automática nem auto-update; em `--global`, a única reconfiguração de agentes é o guia aditivo e as integrações de interface, ambos desligáveis (`--no-guide`, `--no-integrations`). O CLI mostra complementaridade por role/owner/handoffs e overlays, mas só instala as selecionadas; `--with-handoffs` é opt-in para handoffs diretos. Operações especializadas podem depender de ferramentas explicitamente documentadas: `crow-interface`, por exemplo, inclui adaptador de motor versionado e instalação explícita dessa dependência. A descoberta depende do runtime; presença do arquivo não prova carregamento.
44
+ Os nomes têm prefixo `crow-` para coexistir com o catálogo anterior. Não há instalação global automática; auto-update só depois de `crow-skills autoupdate on` (agendador do usuário, desligável com `autoupdate off`), e ele nunca sobrescreve skills editadas. Em `--global`, a única reconfiguração de agentes é o guia aditivo e as integrações de interface (incluindo os hooks do modo crow), ambos desligáveis (`--no-guide`, `--no-integrations`). O CLI mostra complementaridade por role/owner/handoffs e overlays, mas só instala as selecionadas; `--with-handoffs` é opt-in para handoffs diretos. Operações especializadas podem depender de ferramentas explicitamente documentadas: `crow-interface`, por exemplo, inclui adaptador de motor versionado e instalação explícita dessa dependência. A descoberta depende do runtime; presença do arquivo não prova carregamento.
44
45
 
45
46
  Cada pacote mantém suas referências locais e declara dependências operacionais. Os nomes de próximos responsáveis são sugestões, não downloads implícitos. Sem ferramenta necessária, entregue somente o que puder comprovar e descreva a capacidade não executada, em vez de alegar equivalência.
46
47
 
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.7.1"
3
+ __version__ = "0.8.0"
@@ -0,0 +1,398 @@
1
+ """Scheduled self-upgrade of crow-skills and update of the managed skills.
2
+
3
+ ``on`` installs a user scheduler (macOS launchd LaunchAgent, Linux systemd
4
+ --user timer) that runs ``crow-skills autoupdate run`` daily and at login.
5
+ A run upgrades crow-skills the way it was installed (Homebrew tap, npm
6
+ global; a Git checkout only when opted in with --git-pull and the checkout is
7
+ clean on main/master), then updates every managed skill for every agent that
8
+ has managed installs, with the same rules as ``update --global``: guides and
9
+ integrations are regenerated, locally modified skills are never overwritten
10
+ (they are reported), and a refusal for one agent never stops the others.
11
+
12
+ Results go to ~/.local/state/crow-skills/autoupdate/state.json (last run and
13
+ a short history), shown by ``autoupdate status``, ``doctor`` and
14
+ ``events snapshot``. Command output of brew/npm/git is never stored or
15
+ printed: only exit codes and versions.
16
+ """
17
+ from datetime import datetime, timezone
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ import plistlib
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+
27
+ from . import __version__
28
+ from .agents import destinations
29
+ from .guide import protect, read_regular, sha, write_atomic
30
+ from .source import CrowError, Source, same_source
31
+ from .storage import atomic_json, checked, private_dir, read_json
32
+
33
+ LABEL = "com.andersonflima.crow-skills.autoupdate"
34
+ UNIT = "crow-skills-autoupdate"
35
+ FORMULA = "andersonflima/tap/crow-skills"
36
+ PACKAGE = "@andespindola/crow-skills"
37
+ HEADER = "# Gerenciado por crow-skills autoupdate. Não edite; use crow-skills autoupdate off."
38
+ AGENT_ORDER = ("claude", "codex", "gemini", "devin", "uniflow")
39
+ HISTORY = 20
40
+ DAILY = {"Hour": 10, "Minute": 17}
41
+ UNSAFE = re.compile(r"[\"'\\$`\n%]")
42
+
43
+
44
+ def now():
45
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
46
+
47
+
48
+ def root(store):
49
+ return store.root / "autoupdate"
50
+
51
+
52
+ def state_path(store):
53
+ return root(store) / "state.json"
54
+
55
+
56
+ def load_state(store):
57
+ path = checked(state_path(store))
58
+ if not path.exists():
59
+ return {"schema_version": 1, "enabled": False}
60
+ data = read_json(path)
61
+ if not isinstance(data, dict) or data.get("schema_version") != 1:
62
+ raise CrowError("invalid_state", "Estado do autoupdate inválido; nada alterado.")
63
+ return data
64
+
65
+
66
+ def save_state(store, data):
67
+ private_dir(root(store))
68
+ atomic_json(state_path(store), data)
69
+
70
+
71
+ # --- how crow-skills is installed --------------------------------------------------------
72
+
73
+ def detect(source_root=None):
74
+ """kind brew|npm|git|unknown, the stable launcher and the upgrade command."""
75
+ base = Path(source_root or Path(__file__).resolve().parents[1]).resolve()
76
+ text = str(base)
77
+ brew = re.fullmatch(r"(.*)/Cellar/crow-skills/([^/]+)/libexec", text)
78
+ if brew:
79
+ prefix = Path(brew.group(1))
80
+ return {"kind": "brew", "root": text, "version": brew.group(2), "launcher": str(prefix / "bin" / "crow-skills"),
81
+ "upgrade": [str(prefix / "bin" / "brew"), "upgrade", FORMULA]}
82
+ npm = re.fullmatch(r"(.*)/lib/node_modules/@andespindola/crow-skills", text)
83
+ if npm:
84
+ prefix = Path(npm.group(1))
85
+ tool = prefix / "bin" / "npm"
86
+ return {"kind": "npm", "root": text, "version": __version__, "launcher": str(prefix / "bin" / "crow-skills"),
87
+ "upgrade": [str(tool) if tool.exists() else (shutil.which("npm") or "npm"), "install", "-g", PACKAGE + "@latest"]}
88
+ if (base / ".git").exists():
89
+ return {"kind": "git", "root": text, "version": __version__, "launcher": str(base / "bin" / "crow-skills"),
90
+ "upgrade": None}
91
+ return {"kind": "unknown", "root": text, "version": __version__, "launcher": shutil.which("crow-skills"), "upgrade": None}
92
+
93
+
94
+ def platform_kind():
95
+ if sys.platform == "darwin":
96
+ return "launchd"
97
+ if sys.platform.startswith("linux"):
98
+ return "systemd"
99
+ return None
100
+
101
+
102
+ def real_home(home):
103
+ return Path(home).resolve() == Path.home().resolve()
104
+
105
+
106
+ # --- scheduler files ---------------------------------------------------------------------------
107
+
108
+ def search_path(launcher):
109
+ dirs = [os.path.dirname(launcher)]
110
+ for tool in ("node", "python3", "git", "brew", "npm"):
111
+ found = shutil.which(tool)
112
+ if found:
113
+ dirs.append(os.path.dirname(found))
114
+ dirs += ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
115
+ return ":".join(dict.fromkeys(d for d in dirs if d))
116
+
117
+
118
+ def scheduler_files(kind, store, home, launcher):
119
+ """{path: bytes} of the scheduler definition."""
120
+ log = str(root(store) / "scheduler.log")
121
+ args = [launcher, "autoupdate", "run", "--yes", "--json"]
122
+ if kind == "launchd":
123
+ plist = {"Label": LABEL, "ProgramArguments": args, "RunAtLoad": True, "StartCalendarInterval": DAILY,
124
+ "EnvironmentVariables": {"PATH": search_path(launcher)}, "StandardOutPath": log, "StandardErrorPath": log,
125
+ "ProcessType": "Background", "LowPriorityIO": True, "Nice": 10}
126
+ return {Path(home) / "Library" / "LaunchAgents" / (LABEL + ".plist"): plistlib.dumps(plist, sort_keys=True)}
127
+ if kind == "systemd":
128
+ if any(UNSAFE.search(a) for a in args + [log, search_path(launcher)]) or " " in search_path(launcher):
129
+ raise CrowError("unsafe_path", "Caminho do launcher contém aspas, $, % ou barra invertida; agende manualmente.")
130
+ command = " ".join('"' + a + '"' if " " in a else a for a in args)
131
+ base = Path(home) / ".config" / "systemd" / "user"
132
+ service = (HEADER + "\n[Unit]\nDescription=crow-skills autoupdate\n\n[Service]\nType=oneshot\n"
133
+ "Environment=PATH=" + search_path(launcher) + "\nExecStart=" + command + "\nNice=10\n")
134
+ timer = (HEADER + "\n[Unit]\nDescription=crow-skills autoupdate (diário e no login)\n\n[Timer]\nOnCalendar=daily\n"
135
+ "OnStartupSec=5min\nPersistent=true\nRandomizedDelaySec=30min\nUnit=" + UNIT + ".service\n\n"
136
+ "[Install]\nWantedBy=timers.target\n")
137
+ return {base / (UNIT + ".service"): service.encode("utf-8"), base / (UNIT + ".timer"): timer.encode("utf-8")}
138
+ raise CrowError("unsupported_platform", "Sem agendador suportado nesta plataforma (launchd no macOS, systemd --user no Linux). "
139
+ "Agende manualmente: crow-skills autoupdate run --yes --json, uma vez por dia (ex.: cron).")
140
+
141
+
142
+ def run_quiet(command, timeout=60, cwd=None, env=None):
143
+ """Exit code only; output is discarded so nothing sensitive reaches state or logs."""
144
+ try:
145
+ done = subprocess.run(command, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
146
+ stderr=subprocess.DEVNULL, timeout=timeout)
147
+ return done.returncode
148
+ except (OSError, subprocess.SubprocessError):
149
+ return None
150
+
151
+
152
+ def activate(kind, files, home, enable):
153
+ """Load/unload the scheduler; only for the real profile (an isolated --home is never activated)."""
154
+ if not real_home(home):
155
+ return False, "Perfil isolado (--home): arquivos gravados, agendador não carregado."
156
+ if kind == "launchd":
157
+ plist = next(iter(files))
158
+ domain = "gui/" + str(os.getuid())
159
+ run_quiet(["launchctl", "bootout", domain + "/" + LABEL])
160
+ if not enable:
161
+ return True, None
162
+ code = run_quiet(["launchctl", "bootstrap", domain, str(plist)])
163
+ return code == 0, None if code == 0 else "launchctl bootstrap falhou (código " + str(code) + "); carregue com: launchctl bootstrap " + domain + " " + str(plist)
164
+ if not shutil.which("systemctl"):
165
+ return False, "systemctl indisponível; habilite manualmente: systemctl --user enable --now " + UNIT + ".timer"
166
+ if enable:
167
+ run_quiet(["systemctl", "--user", "daemon-reload"])
168
+ code = run_quiet(["systemctl", "--user", "enable", "--now", UNIT + ".timer"])
169
+ return code == 0, None if code == 0 else "systemctl --user enable falhou (código " + str(code) + ")."
170
+ run_quiet(["systemctl", "--user", "disable", "--now", UNIT + ".timer"])
171
+ run_quiet(["systemctl", "--user", "daemon-reload"])
172
+ return True, None
173
+
174
+
175
+ def turn_on(store, home, dry_run=False, git_pull=False, launcher=None, kind=None):
176
+ state = load_state(store)
177
+ kind = kind or platform_kind()
178
+ install = detect()
179
+ launcher = launcher or install["launcher"]
180
+ if not launcher:
181
+ raise CrowError("launcher_not_found", "Não encontrei um launcher estável do crow-skills (brew, npm ou checkout).")
182
+ files = scheduler_files(kind, store, home, launcher)
183
+ previous = {f["path"]: f["sha256"] for f in (state.get("scheduler") or {}).get("files", [])}
184
+ changes = []
185
+ for path, data in files.items():
186
+ path = protect(path, home)
187
+ current = read_regular(path)
188
+ if current is not None and previous.get(str(path)) != sha(current):
189
+ raise CrowError("unmanaged_conflict", str(path) + " existe e não foi gravado pelo crow-skills; nada alterado.")
190
+ changes.append({"path": str(path), "action": "unchanged" if current == data else ("update" if current else "install")})
191
+ plan = {"scheduler": kind, "launcher": launcher, "install": install, "changes": changes, "git_pull": git_pull}
192
+ if dry_run:
193
+ return dict(plan, applied=False)
194
+ for path, data in files.items():
195
+ if not path.parent.exists():
196
+ path.parent.mkdir(parents=True, mode=0o755)
197
+ if read_regular(protect(path, home)) != data:
198
+ write_atomic(path, data, 0o644)
199
+ activated, note = activate(kind, files, home, True)
200
+ state.update(enabled=True, git_pull=bool(git_pull), scheduler={
201
+ "kind": kind, "label": LABEL if kind == "launchd" else UNIT, "launcher": launcher, "activated": activated,
202
+ "note": note, "installedAt": now(), "files": [{"path": str(p), "sha256": sha(d)} for p, d in files.items()]})
203
+ save_state(store, state)
204
+ return dict(plan, applied=True, activated=activated, note=note)
205
+
206
+
207
+ def turn_off(store, home, dry_run=False):
208
+ state = load_state(store)
209
+ scheduler = state.get("scheduler") or {}
210
+ changes, kept = [], []
211
+ for item in scheduler.get("files", []):
212
+ path = protect(Path(item["path"]), home)
213
+ current = read_regular(path)
214
+ if current is None:
215
+ continue
216
+ if sha(current) != item["sha256"]:
217
+ kept.append({"path": str(path), "reason": "Arquivo alterado depois da instalação; mantido. Ele ainda pode ser "
218
+ "carregado no próximo login: revise e remova-o você mesmo."})
219
+ continue
220
+ changes.append({"path": str(path), "action": "remove"})
221
+ if dry_run:
222
+ return {"changes": changes, "kept": kept, "applied": False}
223
+ if scheduler.get("kind"):
224
+ activate(scheduler["kind"], {Path(f["path"]): b"" for f in scheduler.get("files", [])}, home, False)
225
+ for change in changes:
226
+ Path(change["path"]).unlink()
227
+ state.update(enabled=False)
228
+ state.pop("scheduler", None)
229
+ save_state(store, state)
230
+ return {"changes": changes, "kept": kept, "applied": True}
231
+
232
+
233
+ def status(store, home):
234
+ state = load_state(store)
235
+ scheduler = state.get("scheduler") or {}
236
+ files = []
237
+ for item in scheduler.get("files", []):
238
+ try:
239
+ current = read_regular(protect(Path(item["path"]), home))
240
+ except CrowError:
241
+ current = None
242
+ files.append({"path": item["path"], "status": "installed" if current is not None and sha(current) == item["sha256"]
243
+ else ("missing" if current is None else "modified")})
244
+ last = state.get("lastRun")
245
+ return {"enabled": bool(state.get("enabled")), "scheduler": {k: v for k, v in scheduler.items() if k != "files"} or None,
246
+ "files": files, "install": detect(), "git_pull": bool(state.get("git_pull")),
247
+ "lastRun": last, "healthy": (not state.get("enabled") or all(f["status"] == "installed" for f in files))
248
+ and (last is None or bool(last.get("ok")))}
249
+
250
+
251
+ # --- run ----------------------------------------------------------------------------------------
252
+
253
+ def git_upgrade(base):
254
+ git = ["git", "--no-replace-objects", "-c", "core.hooksPath=/dev/null", "-C", base]
255
+ try:
256
+ dirty = subprocess.run(git + ["status", "--porcelain"], capture_output=True, text=True, timeout=30).stdout.strip()
257
+ branch = subprocess.run(git + ["rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=30).stdout.strip()
258
+ except (OSError, subprocess.SubprocessError):
259
+ return {"result": "failed", "reason": "git indisponível"}
260
+ if dirty:
261
+ return {"result": "skipped", "reason": "checkout com alterações locais; não atualizado"}
262
+ if branch not in ("main", "master"):
263
+ return {"result": "skipped", "reason": "checkout fora de main/master (" + branch[:60] + "); não atualizado"}
264
+ code = run_quiet(git + ["pull", "--ff-only", "--no-rebase", "-q"], timeout=300)
265
+ return {"result": "ok" if code == 0 else "failed", "exitCode": code}
266
+
267
+
268
+ def launcher_version(launcher):
269
+ try:
270
+ done = subprocess.run([launcher, "--version", "--json"], capture_output=True, text=True, timeout=60,
271
+ stdin=subprocess.DEVNULL)
272
+ return json.loads(done.stdout)["data"]["version"]
273
+ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError):
274
+ return None
275
+
276
+
277
+ def self_upgrade(install, git_pull):
278
+ if install["kind"] in ("brew", "npm"):
279
+ code = run_quiet(install["upgrade"], timeout=900)
280
+ after = launcher_version(install["launcher"]) if code == 0 else None
281
+ return {"kind": install["kind"], "result": "ok" if code == 0 else "failed", "exitCode": code,
282
+ "before": __version__, "after": after}
283
+ if install["kind"] == "git":
284
+ if not git_pull:
285
+ return {"kind": "git", "result": "skipped", "reason": "checkout git: atualize com git pull, ou ligue com autoupdate on --git-pull"}
286
+ result = git_upgrade(install["root"])
287
+ result.update(kind="git", before=__version__, after=launcher_version(install["launcher"]) if result["result"] == "ok" else None)
288
+ return result
289
+ return {"kind": "unknown", "result": "skipped", "reason": "instalação não reconhecida (brew, npm ou checkout git)"}
290
+
291
+
292
+ def skills_update(home):
293
+ """update --global for every agent with managed installs; modified skills are kept and reported."""
294
+ from . import cli
295
+ from .cli import parser, run as cli_run
296
+ from .storage import Store
297
+ store = Store(home)
298
+ try:
299
+ current = Source(cli.DEFAULT_SOURCE)
300
+ except CrowError:
301
+ current = None
302
+ results, seen = [], set()
303
+ for agent in AGENT_ORDER:
304
+ try:
305
+ target = destinations(agent, home)[0]
306
+ except CrowError:
307
+ continue
308
+ if target["path"] in seen:
309
+ continue
310
+ seen.add(target["path"])
311
+ try:
312
+ rows = store.status([target])
313
+ except (CrowError, OSError) as exc:
314
+ results.append({"agent": agent, "error": {"code": getattr(exc, "code", "io_error"), "message": str(exc)}})
315
+ continue
316
+ rows = [r for r in rows if r["name"] and r["revision"]]
317
+ if not rows:
318
+ continue
319
+ records = store.state(target["path"])["installed"]
320
+ names, kept = [], []
321
+ for r in rows:
322
+ if r["status"] not in ("installed", "disabled"):
323
+ kept.append({"name": r["name"], "status": r["status"]})
324
+ elif current is not None and not same_source(records.get(r["name"], {}), current):
325
+ kept.append({"name": r["name"], "status": "other_origin"}) # update --source or install switches it
326
+ else:
327
+ names.append(r["name"])
328
+ entry = {"agent": agent, "target": target["path"], "updated": [], "unchanged": 0, "kept": kept, "error": None}
329
+ if names:
330
+ try:
331
+ args = parser().parse_args(["update", *names, "--agents", agent, "--global", "--yes", "--json", "--home", str(home)])
332
+ data = cli_run(args)
333
+ entry["updated"] = sorted({c["name"] for c in data["changes"] if c["action"] != "unchanged"})
334
+ entry["unchanged"] = sum(c["action"] == "unchanged" for c in data["changes"])
335
+ entry["transaction"] = data.get("transaction")
336
+ entry["guides"] = (data.get("guides") or {}).get("transaction")
337
+ except CrowError as exc:
338
+ entry["error"] = {"code": exc.code, "message": str(exc)}
339
+ except (OSError, ValueError, KeyError, TypeError):
340
+ entry["error"] = {"code": "io_error", "message": "Falha de leitura/gravação; nada concluído para este agente."}
341
+ results.append(entry)
342
+ return results
343
+
344
+
345
+ def run(store, home, self_step=True, skills_step=True):
346
+ try:
347
+ import fcntl
348
+ except ImportError as exc: # pragma: no cover
349
+ raise CrowError("unsupported_platform", "Autoupdate requer locks POSIX.") from exc
350
+ directory = private_dir(root(store))
351
+ lock = os.open(checked(directory / "run.lock"), os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
352
+ try:
353
+ try:
354
+ # The new version started by a self-upgrade runs under its parent's lock.
355
+ if os.environ.get("CROW_SKILLS_AUTOUPDATE_CHILD") != "1":
356
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
357
+ except OSError as exc:
358
+ raise CrowError("busy", "Outro autoupdate está em execução.") from exc
359
+ log = directory / "scheduler.log"
360
+ if log.exists() and log.stat().st_size > 256 * 1024:
361
+ log.write_text("")
362
+ state = load_state(store)
363
+ install = detect()
364
+ result = {"startedAt": now(), "version": __version__, "install": {"kind": install["kind"]}}
365
+ handed_off = False
366
+ if self_step:
367
+ result["self"] = self_upgrade(install, state.get("git_pull"))
368
+ after = result["self"].get("after")
369
+ if skills_step and after and after != __version__ and install["launcher"]:
370
+ # The new version updates the skills (its own snapshot is the new source).
371
+ try:
372
+ done = subprocess.run([install["launcher"], "autoupdate", "run", "--skills-only", "--yes", "--json"],
373
+ capture_output=True, text=True, timeout=1200, stdin=subprocess.DEVNULL,
374
+ env=dict(os.environ, CROW_SKILLS_AUTOUPDATE_CHILD="1"))
375
+ child = json.loads(done.stdout)
376
+ result["skills"] = child["data"]["lastRun"]["skills"] if child.get("ok") else []
377
+ result["handoff"] = {"version": after, "ok": bool(child.get("ok"))}
378
+ handed_off = True
379
+ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError):
380
+ result["handoff"] = {"version": after, "ok": False}
381
+ handed_off = True
382
+ if skills_step and not handed_off:
383
+ result["skills"] = skills_update(home)
384
+ result["endedAt"] = now()
385
+ result["ok"] = (result.get("self", {}).get("result") != "failed"
386
+ and all(not s.get("error") for s in result.get("skills", []))
387
+ and result.get("handoff", {}).get("ok", True))
388
+ state = load_state(store)
389
+ state["lastRun"] = result
390
+ history = state.get("history") or []
391
+ history.append({"startedAt": result["startedAt"], "ok": result["ok"],
392
+ "updated": sum(len(s.get("updated") or []) for s in result.get("skills", [])),
393
+ "self": (result.get("self") or {}).get("result")})
394
+ state["history"] = history[-HISTORY:]
395
+ save_state(store, state)
396
+ return {"lastRun": result, "enabled": bool(state.get("enabled"))}
397
+ finally:
398
+ os.close(lock)
@@ -6,7 +6,10 @@ from pathlib import Path
6
6
  import sys
7
7
 
8
8
  from . import __version__
9
+ from . import autoupdate
10
+ from . import events
9
11
  from . import guide
12
+ from . import hook
10
13
  from . import integrations
11
14
  from . import progress
12
15
  from .agents import AGENTS, destinations, inventory
@@ -14,7 +17,7 @@ from .source import CrowError, Source, valid_name
14
17
  from .storage import Store, checked
15
18
 
16
19
  DEFAULT_SOURCE = Path(__file__).resolve().parents[1]
17
- MUTATIONS = {"install", "update", "uninstall", "rollback", "recover", "guide", "integrations"}
20
+ MUTATIONS = {"install", "update", "uninstall", "rollback", "recover", "guide", "integrations", "mode", "autoupdate"}
18
21
 
19
22
 
20
23
  class Parser(argparse.ArgumentParser):
@@ -68,6 +71,17 @@ def parser():
68
71
  child.add_argument("--watch", action="store_true", help="Redesenhar até a execução terminar ou Ctrl-C")
69
72
  child.add_argument("--run", help="ID da execução; padrão: a ativa mais recente do diretório")
70
73
  child.add_argument("--cwd", help="Diretório da execução; padrão: o atual")
74
+ child = sub.add_parser("mode", parents=[common], help="Liga/desliga o modo crow (crow-behaviour) em todas as sessões e CLIs")
75
+ child.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
76
+ child = sub.add_parser("events", parents=[common], help="Barramento de eventos dos hooks (crow-event/1) para terminais")
77
+ child.add_argument("action", nargs="?", default="snapshot", choices=("snapshot", "tail"))
78
+ child.add_argument("--limit", type=int, default=50, help="tail: quantos eventos (padrão 50; 0 = todos os retidos)")
79
+ child.add_argument("--follow", action="store_true", help="tail: transmitir eventos novos em JSONL até Ctrl-C")
80
+ child = sub.add_parser("autoupdate", parents=[common], help="Atualização diária e no login do crow-skills e das skills gerenciadas")
81
+ child.add_argument("action", choices=("on", "off", "status", "run"))
82
+ child.add_argument("--git-pull", action="store_true", help="on: também atualizar um checkout git limpo em main/master (git pull --ff-only)")
83
+ child.add_argument("--skills-only", action="store_true", help="run: só atualizar as skills, sem atualizar o crow-skills")
84
+ child.add_argument("--no-self", action="store_true", help="run: igual a --skills-only")
71
85
  for name in ("rollback", "recover"):
72
86
  child = sub.add_parser(name, parents=[common])
73
87
  child.add_argument("--transaction", required=name == "rollback")
@@ -313,6 +327,61 @@ def progress_command(args, home):
313
327
  return data
314
328
 
315
329
 
330
+ def mode_command(args, home):
331
+ current = hook.read_mode(home)
332
+ if args.action in ("on", "off", "toggle") and not getattr(args, "dry_run", False):
333
+ wanted = args.action if args.action != "toggle" else ("off" if current["mode"] == "on" else "on")
334
+ current = hook.write_mode(home, wanted, "cli")
335
+ try:
336
+ installed = [r["integration"] for r in integrations.status(Store(home), home)
337
+ if r["status"] == "installed" and r["integration"].endswith(("-hooks", "-statusline", "-command", "tmux-status"))]
338
+ except CrowError:
339
+ installed = []
340
+ return {**current, "indicator": hook.indicator(current["mode"]), "integrations": installed,
341
+ "applies": "A partir do próximo prompt em toda sessão com o hook do crow-skills; sessões sem hook leem o modo ao abrir "
342
+ "(guia) ou pelo usuário (crow-skills mode status).",
343
+ "dry_run": getattr(args, "dry_run", False)}
344
+
345
+
346
+ def events_command(args, home, store):
347
+ if args.action == "tail":
348
+ if args.follow:
349
+ if getattr(args, "json", False):
350
+ raise CrowError("usage", "--follow transmite JSONL puro; não combine com --json.")
351
+ events.follow(home)
352
+ return None
353
+ return {"events": hook.tail(home, max(0, args.limit)), "schema": hook.EVENT_SCHEMA,
354
+ "path": str(hook.events_dir(home) / "events.jsonl")}
355
+ data = events.snapshot(home, store)
356
+ data["text"] = events.render(data, progress.unicode_ok())
357
+ return data
358
+
359
+
360
+ def autoupdate_command(args, home, store, interactive):
361
+ if args.action == "status":
362
+ return autoupdate.status(store, home)
363
+ if args.action == "run":
364
+ authorize(args, interactive, {"autoupdate": "run", "self_upgrade": not (args.skills_only or args.no_self)})
365
+ if getattr(args, "dry_run", False):
366
+ return {"install": autoupdate.detect(), "applied": False,
367
+ "would": ["atualizar o crow-skills" if not (args.skills_only or args.no_self) else None,
368
+ "update --global das skills gerenciadas, por agente"]}
369
+ return autoupdate.run(store, home, self_step=not (args.skills_only or args.no_self))
370
+ if args.action == "on":
371
+ preview = autoupdate.turn_on(store, home, dry_run=True, git_pull=args.git_pull)
372
+ if any(c["action"] != "unchanged" for c in preview["changes"]):
373
+ authorize(args, interactive, preview)
374
+ if getattr(args, "dry_run", False):
375
+ return preview
376
+ return autoupdate.turn_on(store, home, git_pull=args.git_pull)
377
+ preview = autoupdate.turn_off(store, home, dry_run=True)
378
+ if preview["changes"]:
379
+ authorize(args, interactive, preview)
380
+ if getattr(args, "dry_run", False):
381
+ return preview
382
+ return autoupdate.turn_off(store, home)
383
+
384
+
316
385
  def resync_integrations(home, store):
317
386
  """After rollback/recover, drop integrations whose agents no longer have crow-behaviour."""
318
387
  try:
@@ -387,6 +456,12 @@ def run(args):
387
456
  return integrations_command(args, home, store, interactive)
388
457
  if args.command == "progress":
389
458
  return progress_command(args, home)
459
+ if args.command == "mode":
460
+ return mode_command(args, home)
461
+ if args.command == "events":
462
+ return events_command(args, home, store)
463
+ if args.command == "autoupdate":
464
+ return autoupdate_command(args, home, store, interactive)
390
465
  if args.command in ("status", "doctor"):
391
466
  targets = scope(args, home, read=True)
392
467
  rows = store.status(targets)
@@ -399,9 +474,15 @@ def run(args):
399
474
  except CrowError as exc:
400
475
  ui = [{"integration": "*", "status": "unsafe", "note": str(exc)}]
401
476
  result = {"installations": rows, "guides": guides, "integrations": ui, "pending_transactions": pending,
402
- "activation_verified": False, "reload_hint": "Peça ao host para recarregar/listar skills; reinicie a sessão se necessário."}
477
+ "mode": hook.read_mode(home), "activation_verified": False,
478
+ "reload_hint": "Peça ao host para recarregar/listar skills; reinicie a sessão se necessário."}
479
+ try:
480
+ result["autoupdate"] = autoupdate.status(store, home) if global_view else None
481
+ except CrowError as exc:
482
+ result["autoupdate"] = {"enabled": None, "healthy": False, "error": exc.code}
403
483
  if args.command == "doctor":
404
- result.update(healthy=not pending and all(r["status"] not in ("modified", "missing", "unsafe", "external_link") for r in rows + guides),
484
+ result.update(healthy=not pending and all(r["status"] not in ("modified", "missing", "unsafe", "external_link") for r in rows + guides)
485
+ and (result["autoupdate"] or {}).get("healthy", True) is not False,
405
486
  agents=inventory(home))
406
487
  return result
407
488
  if args.command in ("rollback", "recover"):
@@ -505,6 +586,11 @@ def emit(command, data, machine):
505
586
  elif command == "progress":
506
587
  if data is not None:
507
588
  print(data["text"] or "crow: nenhuma execução registrada neste diretório.")
589
+ elif command == "mode":
590
+ print(data["indicator"] + " — " + data["applies"])
591
+ elif command == "events":
592
+ if data is not None:
593
+ print(data["text"] if "text" in data else "\n".join(json.dumps(e, ensure_ascii=False) for e in data["events"]))
508
594
  elif command == "list":
509
595
  print("Fonte: " + data["revision"])
510
596
  for item in data["skills"]: