@andespindola/crow-skills 0.15.0 → 0.16.1
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/crow_skills/__init__.py +1 -1
- package/crow_skills/agents.py +108 -4
- package/crow_skills/broadcast.py +142 -20
- package/crow_skills/cli.py +26 -3
- package/crow_skills/guide.py +13 -3
- package/crow_skills/storage.py +117 -12
- package/docs/cli.md +19 -1
- package/package.json +1 -1
- package/release.json +10 -10
package/crow_skills/__init__.py
CHANGED
package/crow_skills/agents.py
CHANGED
|
@@ -37,12 +37,25 @@ def codex_home(home):
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def codex_skills_dir(home):
|
|
40
|
-
return codex_home(home) / "skills"
|
|
40
|
+
return Path(physical(codex_home(home) / "skills"))
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def legacy_shared_dir(home):
|
|
44
44
|
"""Where codex installs landed until 0.14.0; read only, to migrate what is still there."""
|
|
45
|
-
return Path(
|
|
45
|
+
return Path(physical(Path(home) / LEGACY_SHARED))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def physical(path):
|
|
49
|
+
"""The physical path behind a skills directory, following symlinks on the components that exist.
|
|
50
|
+
|
|
51
|
+
A CLI directory kept in dotfiles (~/.codex -> /dotfiles/codex) is the normal case, and the CLI
|
|
52
|
+
itself follows the link. storage.checked() refuses to WRITE through a symlink, which is the
|
|
53
|
+
control that keeps a write from landing somewhere other than where it reads: resolving here, at
|
|
54
|
+
destination resolution, honours the dotfiles layout while leaving that control intact, because
|
|
55
|
+
what reaches checked() is already physical. os.path.realpath resolves the existing components
|
|
56
|
+
and leaves the rest, so a skills dir that does not exist yet still resolves correctly.
|
|
57
|
+
"""
|
|
58
|
+
return os.path.realpath(os.path.abspath(Path(path).expanduser()))
|
|
46
59
|
|
|
47
60
|
|
|
48
61
|
def real_home(home):
|
|
@@ -86,7 +99,7 @@ def destinations(names, home, project=None, custom=None):
|
|
|
86
99
|
if custom:
|
|
87
100
|
if names:
|
|
88
101
|
raise CrowError("usage", "--dest e --agents não podem ser combinados.")
|
|
89
|
-
return [{"path":
|
|
102
|
+
return [{"path": physical(custom), "agents": ["custom"]}]
|
|
90
103
|
if not names:
|
|
91
104
|
raise CrowError("usage", "Selecione --agents; não há instalação implícita em todos os agentes.")
|
|
92
105
|
selected = names.split(",") if isinstance(names, str) else names
|
|
@@ -109,13 +122,104 @@ def destinations(names, home, project=None, custom=None):
|
|
|
109
122
|
path = codex_skills_dir(home)
|
|
110
123
|
if name == "uniflow":
|
|
111
124
|
path = uniflow_store(home)
|
|
112
|
-
key =
|
|
125
|
+
key = physical(path)
|
|
113
126
|
result.setdefault(key, {"path": key, "agents": []})
|
|
114
127
|
if name not in result[key]["agents"]:
|
|
115
128
|
result[key]["agents"].append(name)
|
|
116
129
|
return list(result.values())
|
|
117
130
|
|
|
118
131
|
|
|
132
|
+
def plausible_skills_dir(path, agents_):
|
|
133
|
+
"""O caminho resolvido parece o diretório de skills DESTE CLI?
|
|
134
|
+
|
|
135
|
+
Regra positiva, por decisão do dono. "Termina em skills" sozinho não basta: o arranjo de
|
|
136
|
+
dotfiles mais comum é `~/.claude/skills -> /dotfiles/claude`, em que o LINK se chama skills e o
|
|
137
|
+
ALVO se chama como o agente — e recusá-lo quebraria exatamente o caso que a resolução existe
|
|
138
|
+
para atender. Então o último trecho pode ser "skills" OU o nome de um dos agentes selecionados
|
|
139
|
+
(ou o nome do diretório de config dele, para o devin). `~/.ssh`, o `.git` de outro repositório
|
|
140
|
+
e o diretório de transações do crow-skills continuam fora.
|
|
141
|
+
"""
|
|
142
|
+
# Um trecho sensível no caminho vence o nome: `~/.ssh/skills` passava só por terminar em
|
|
143
|
+
# "skills" e o instalador criava ali seu diretório de staging, ao lado das chaves. Isto não é
|
|
144
|
+
# uma lista de negação geral (que o dono recusou): são três áreas que nunca são diretório de
|
|
145
|
+
# skills de um CLI.
|
|
146
|
+
parts = {x.lower() for x in Path(path).parts}
|
|
147
|
+
if parts & {".ssh", ".git", ".gnupg"}:
|
|
148
|
+
return False
|
|
149
|
+
base = os.path.basename(path).lower()
|
|
150
|
+
if base == "skills":
|
|
151
|
+
return True
|
|
152
|
+
names = set()
|
|
153
|
+
for agent in agents_:
|
|
154
|
+
names.add(agent.lower())
|
|
155
|
+
user_path = (AGENTS.get(agent) or (None,))[0]
|
|
156
|
+
if user_path:
|
|
157
|
+
parts = [x for x in Path(user_path).parts if x not in (".", "")]
|
|
158
|
+
if len(parts) >= 2:
|
|
159
|
+
names.add(parts[-2].lstrip(".").lower()) # .config/devin/skills -> devin
|
|
160
|
+
names.add(parts[0].lstrip(".").lower()) # .claude/skills -> claude
|
|
161
|
+
return base in names
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def refuse_implausible_destination(targets, home):
|
|
165
|
+
"""Recusa um destino de CLI que resolve para um lugar que não é diretório de skills.
|
|
166
|
+
|
|
167
|
+
Resolver symlink (0.16.0) tirou a garantia implícita de que a seleção de um CLI só escrevia
|
|
168
|
+
embaixo do diretório daquele CLI: um ~/.claude/skills apontando para ~/.ssh, para o .git de
|
|
169
|
+
outro repositório ou para o próprio diretório de transações do crow-skills passava a receber
|
|
170
|
+
pacotes. Regra POSITIVA, por decisão do dono: o caminho resolvido precisa terminar em "skills"
|
|
171
|
+
(o layout de dotfiles normal, /dotfiles/claude/skills, passa) ou ser o store do Uniflow pelo
|
|
172
|
+
agente certo. Uma lista de negação seria mais fácil de furar com um caminho não previsto.
|
|
173
|
+
"""
|
|
174
|
+
store = physical(uniflow_store(home))
|
|
175
|
+
for target in targets:
|
|
176
|
+
path = physical(target["path"])
|
|
177
|
+
agents_ = target.get("agents") or []
|
|
178
|
+
if "custom" in agents_:
|
|
179
|
+
continue # --dest é escolha explícita e consciente do usuário
|
|
180
|
+
if path == store and agents_ == ["uniflow"]:
|
|
181
|
+
continue
|
|
182
|
+
if plausible_skills_dir(path, agents_):
|
|
183
|
+
continue
|
|
184
|
+
# Uma área protegida tem razão PRÓPRIA e melhor: dizer "não parece diretório de skills" ali
|
|
185
|
+
# esconderia o motivo verdadeiro e mandaria o usuário para um --dest que a proteção recusa
|
|
186
|
+
# de qualquer forma — beco sem saída.
|
|
187
|
+
from .guide import shielded
|
|
188
|
+
try:
|
|
189
|
+
if shielded(Path(path), home):
|
|
190
|
+
raise CrowError("protected_path", "O destino de " + ",".join(agents_) + " resolve para " + path +
|
|
191
|
+
", que é área protegida (prompt compartilhado ou configuração do Uniflow).")
|
|
192
|
+
except CrowError:
|
|
193
|
+
raise
|
|
194
|
+
except Exception: # noqa: BLE001 - se não der para avaliar, vale a razão genérica
|
|
195
|
+
pass
|
|
196
|
+
raise CrowError("implausible_destination", "O destino de " + ",".join(agents_) + " resolve para " +
|
|
197
|
+
path + ", que não parece um diretório de skills deste CLI (esperado um caminho cujo "
|
|
198
|
+
"último trecho seja \"skills\" ou o nome do agente). Se é mesmo onde você quer "
|
|
199
|
+
"instalar, use --dest explicitamente.")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def refuse_store_as_cli_dir(targets, home):
|
|
203
|
+
"""Recusa um destino de CLI que resolve para o store do Uniflow.
|
|
204
|
+
|
|
205
|
+
Resolver symlink (0.16.0) tornou isso alcançável pela seleção de um CLI: um
|
|
206
|
+
~/.claude/skills -> ~/.config/uniflow/skills gravaria um pacote dentro do store, que vale para
|
|
207
|
+
TODOS os CLIs que o Uniflow projeta, não só o selecionado. Só vale na MUTAÇÃO: `destinations()`
|
|
208
|
+
é chamado por vários caminhos de leitura (status, doctor, avisos do uniflow) que inspecionam o
|
|
209
|
+
destino de outro agente, e levantar lá derrubaria um comando que nem ia gravar naquele lugar.
|
|
210
|
+
"""
|
|
211
|
+
store = physical(uniflow_store(home))
|
|
212
|
+
for target in targets:
|
|
213
|
+
others = [a for a in target.get("agents") or [] if a != "uniflow"]
|
|
214
|
+
if others and physical(target["path"]) == store:
|
|
215
|
+
# "custom" é o rótulo interno de --dest: dizer "o destino de custom" confunde quem não
|
|
216
|
+
# passou agente nenhum. A mensagem nomeia o que o usuário realmente escreveu.
|
|
217
|
+
quem = "--dest" if others == ["custom"] else ",".join(others)
|
|
218
|
+
raise CrowError("protected_path", "O destino de " + quem + " resolve para o store do Uniflow ("
|
|
219
|
+
+ store + "), que vale para todos os CLIs que ele projeta. Use --agents uniflow "
|
|
220
|
+
"para gravar no store.")
|
|
221
|
+
|
|
222
|
+
|
|
119
223
|
def inventory(home):
|
|
120
224
|
rows = []
|
|
121
225
|
for name, (_, project, binary) in AGENTS.items():
|
package/crow_skills/broadcast.py
CHANGED
|
@@ -548,30 +548,69 @@ TTY_TIMEOUT = 1.0
|
|
|
548
548
|
TTY_TRIES = 2
|
|
549
549
|
|
|
550
550
|
|
|
551
|
+
def parse_elapsed(text):
|
|
552
|
+
"""Segundos de `ps -o etime=` ([[dd-]hh:]mm:ss), ou None quando não dá para ler."""
|
|
553
|
+
if not isinstance(text, str) or not text.strip():
|
|
554
|
+
return None
|
|
555
|
+
body = text.strip()
|
|
556
|
+
days = 0
|
|
557
|
+
if "-" in body:
|
|
558
|
+
head, _, body = body.partition("-")
|
|
559
|
+
try:
|
|
560
|
+
days = int(head)
|
|
561
|
+
except ValueError:
|
|
562
|
+
return None
|
|
563
|
+
pieces = body.split(":")
|
|
564
|
+
if not 1 <= len(pieces) <= 3:
|
|
565
|
+
return None
|
|
566
|
+
try:
|
|
567
|
+
values = [int(x) for x in pieces]
|
|
568
|
+
except ValueError:
|
|
569
|
+
return None
|
|
570
|
+
while len(values) < 3:
|
|
571
|
+
values.insert(0, 0)
|
|
572
|
+
return days * 86400 + values[0] * 3600 + values[1] * 60 + values[2]
|
|
573
|
+
|
|
574
|
+
|
|
551
575
|
def tty_lookup(pid, runner=None):
|
|
552
|
-
"""(tty, answered): the controlling terminal of pid as a device path
|
|
553
|
-
``/dev/pts/3``) or None
|
|
554
|
-
non-POSIX system, so the caller may retry later)
|
|
555
|
-
|
|
576
|
+
"""(tty, answered, elapsed): the controlling terminal of pid as a device path
|
|
577
|
+
(``/dev/ttys012``, ``/dev/pts/3``) or None; whether ``ps`` actually answered (False on error,
|
|
578
|
+
timeout or a non-POSIX system, so the caller may retry later); and how many seconds the process
|
|
579
|
+
has been running, or None.
|
|
580
|
+
|
|
581
|
+
ONE ``ps -o tty=,etime= -p PID`` limited to TTY_TIMEOUT, never raises. The elapsed time rides
|
|
582
|
+
along in the ps that already happens once per stable pid: recording when the CLI started is
|
|
583
|
+
what lets a reader tell "this pid is still the same process" from "the number was recycled",
|
|
584
|
+
and it must cost nothing on the hook's path."""
|
|
556
585
|
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1 or os.name != "posix":
|
|
557
|
-
return None, False
|
|
586
|
+
return None, False, None
|
|
558
587
|
try:
|
|
559
588
|
if runner is None:
|
|
560
589
|
import subprocess
|
|
561
|
-
done = subprocess.run(["ps", "-o", "tty=", "-p", str(pid)], stdin=subprocess.DEVNULL,
|
|
562
|
-
text=True, timeout=TTY_TIMEOUT)
|
|
590
|
+
done = subprocess.run(["ps", "-o", "tty=,etime=", "-p", str(pid)], stdin=subprocess.DEVNULL,
|
|
591
|
+
capture_output=True, text=True, timeout=TTY_TIMEOUT)
|
|
563
592
|
if done.returncode != 0:
|
|
564
|
-
return None, done.returncode == 1 # 1: no such process (a definite answer)
|
|
593
|
+
return None, done.returncode == 1, None # 1: no such process (a definite answer)
|
|
565
594
|
out = done.stdout
|
|
566
595
|
else:
|
|
567
596
|
out = runner(pid)
|
|
568
597
|
except Exception: # noqa: BLE001 - a missing tty is never an error
|
|
569
|
-
return None, False
|
|
570
|
-
|
|
598
|
+
return None, False, None
|
|
599
|
+
line = (out or "").strip().splitlines()[0].strip() if (out or "").strip() else ""
|
|
600
|
+
# "ttys012 01:23:45": o tty é a primeira coluna; o resto, quando existe, é o etime. Um runner
|
|
601
|
+
# de teste que devolva só o tty continua funcionando — o start fica None.
|
|
602
|
+
# Só separa a segunda coluna quando ela REALMENTE parece um etime; caso contrário a linha
|
|
603
|
+
# inteira é o nome do tty, como antes. Dividir por espaço sem checar aceitaria "tty s1" como
|
|
604
|
+
# o tty "tty", afrouxando a validação do nome.
|
|
605
|
+
parts = line.split()
|
|
606
|
+
name, elapsed = line, None
|
|
607
|
+
if len(parts) == 2 and parse_elapsed(parts[1]) is not None:
|
|
608
|
+
name, elapsed = parts[0], parts[1]
|
|
571
609
|
base = name[len("/dev/"):] if name.startswith("/dev/") else name
|
|
610
|
+
seconds = parse_elapsed(elapsed)
|
|
572
611
|
if not base or base.strip("?-") == "" or not re.fullmatch(r"[A-Za-z0-9._-][A-Za-z0-9/._-]{0,63}", base) or ".." in base:
|
|
573
|
-
return None, True
|
|
574
|
-
return "/dev/" + base, True
|
|
612
|
+
return None, True, seconds
|
|
613
|
+
return "/dev/" + base, True, seconds
|
|
575
614
|
|
|
576
615
|
|
|
577
616
|
def tty_of(pid, runner=None):
|
|
@@ -610,12 +649,17 @@ def fold(snap, event, touched=(), now=None, ppid=None, cli_pid=None, tty_runner=
|
|
|
610
649
|
stable = isinstance(pid, int) and (pid == presence.get("cliPid") or presence.get("pidStable"))
|
|
611
650
|
if stable and presence.get("ttyPid") != pid: # one short ps per stable pid (retried if ps did not answer)
|
|
612
651
|
presence.pop("tty", None)
|
|
613
|
-
tty, answered = tty_lookup(pid, tty_runner)
|
|
652
|
+
tty, answered, elapsed = tty_lookup(pid, tty_runner)
|
|
614
653
|
tries = presence.get("ttyTries") if isinstance(presence.get("ttyTries"), list) and presence["ttyTries"][:1] == [pid] else [pid, 0]
|
|
615
654
|
tries = [pid, int(tries[1]) + 1 if isinstance(tries[1], int) else 1]
|
|
616
655
|
presence["ttyTries"] = tries
|
|
617
656
|
if answered or tries[1] >= TTY_TRIES: # a ps that failed or timed out is retried once, never per event
|
|
618
657
|
presence["ttyPid"] = pid
|
|
658
|
+
if isinstance(elapsed, int):
|
|
659
|
+
# Identidade do processo, de graça: o MESMO ps do tty traz o tempo decorrido. Guardar o
|
|
660
|
+
# início permite distinguir "o pid continua vivo" de "o número foi reciclado por outro
|
|
661
|
+
# processo" sem nenhuma chamada a mais no caminho do hook.
|
|
662
|
+
presence["cliStart"] = int(now - elapsed)
|
|
619
663
|
if tty:
|
|
620
664
|
presence["tty"] = tty
|
|
621
665
|
if stable:
|
|
@@ -673,19 +717,97 @@ def presence_of(snap):
|
|
|
673
717
|
return value if isinstance(value, dict) else {}
|
|
674
718
|
|
|
675
719
|
|
|
676
|
-
|
|
677
|
-
|
|
720
|
+
CLI_REUSE_SECONDS = 60 # mesma tolerância de resources.py: start desta distância é o mesmo CLI
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def process_identity(table, pid):
|
|
724
|
+
"""(existe, é zumbi, start) de um pid numa tabela já lida, ou (None, None, None) sem tabela.
|
|
725
|
+
|
|
726
|
+
A tabela vem de quem JÁ leu os processos (crow-skills broadcast status, doctor, varredura de
|
|
727
|
+
órfãos). O caminho do hook não passa tabela e por isso não paga nada: lá vale a checagem barata
|
|
728
|
+
de `pid_alive`. Assim a identidade forte existe onde é de graça, e o contrato do hook
|
|
729
|
+
("retorne rápido, nunca bloqueie") fica intacto.
|
|
730
|
+
"""
|
|
731
|
+
# Tabela VAZIA não é "nenhum processo existe": é `ps` que falhou, deu timeout ou não existe
|
|
732
|
+
# neste sistema. Tratá-la como resposta apagava todas as sessões vivas de uma vez. Sem
|
|
733
|
+
# evidência, vale a checagem barata.
|
|
734
|
+
if not isinstance(table, dict) or not table or not isinstance(pid, int) or isinstance(pid, bool):
|
|
735
|
+
return None, None, None
|
|
736
|
+
row = table.get(pid)
|
|
737
|
+
if row is None:
|
|
738
|
+
return False, None, None
|
|
739
|
+
return True, bool(row.get("zombie")), row.get("start")
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def process_table(runner=None):
|
|
743
|
+
"""{pid: {"start": epoch, "zombie": bool}} num único `ps`, para quem vai avaliar várias sessões.
|
|
744
|
+
|
|
745
|
+
Um ps para a máquina inteira, não um por sessão. Quem não precisa de identidade forte (o hook)
|
|
746
|
+
simplesmente não chama isto."""
|
|
747
|
+
if os.name != "posix":
|
|
748
|
+
return {}
|
|
749
|
+
try:
|
|
750
|
+
if runner is None:
|
|
751
|
+
import subprocess
|
|
752
|
+
done = subprocess.run(["ps", "-Ao", "pid=,etime=,stat="], stdin=subprocess.DEVNULL,
|
|
753
|
+
capture_output=True, text=True, timeout=TTY_TIMEOUT * 3)
|
|
754
|
+
if done.returncode != 0:
|
|
755
|
+
return {}
|
|
756
|
+
out = done.stdout
|
|
757
|
+
else:
|
|
758
|
+
out = runner()
|
|
759
|
+
except Exception: # noqa: BLE001 - sem tabela, a checagem barata continua valendo
|
|
760
|
+
return {}
|
|
761
|
+
moment = time.time()
|
|
762
|
+
table = {}
|
|
763
|
+
for line in (out or "").splitlines():
|
|
764
|
+
parts = line.split()
|
|
765
|
+
if len(parts) < 3:
|
|
766
|
+
continue
|
|
767
|
+
try:
|
|
768
|
+
pid = int(parts[0])
|
|
769
|
+
except ValueError:
|
|
770
|
+
continue
|
|
771
|
+
seconds = parse_elapsed(parts[1])
|
|
772
|
+
table[pid] = {"start": int(moment - seconds) if isinstance(seconds, int) else None,
|
|
773
|
+
"zombie": parts[2][:1] in ("Z", "X")}
|
|
774
|
+
return table
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def liveness(snap, age, cfg, now=None, table=None):
|
|
778
|
+
"""'live' or None: ended sessions, stale ones and ones whose CLI process is gone drop out.
|
|
779
|
+
|
|
780
|
+
A session whose pid is STABLE (the resolved CLI, not a transient hook parent) and whose process
|
|
781
|
+
no longer exists is dead now, not in stale_minutes: a CLI that crashed used to keep claiming
|
|
782
|
+
`working` for half an hour, inflating the count and making every other session avoid its clone.
|
|
783
|
+
pid_alive returning None (it could not tell) is not treated as dead, and a session without a
|
|
784
|
+
stable pid still falls back to the heartbeat window.
|
|
785
|
+
"""
|
|
678
786
|
if snap.get("state") == "ended":
|
|
679
787
|
return None
|
|
788
|
+
presence = presence_of(snap)
|
|
789
|
+
if presence.get("pidStable") and pid_alive(presence.get("pid")) is False:
|
|
790
|
+
return None
|
|
791
|
+
if presence.get("pidStable"):
|
|
792
|
+
exists, is_zombie, start = process_identity(table, presence.get("pid"))
|
|
793
|
+
if exists is False or is_zombie:
|
|
794
|
+
return None # o processo sumiu, ou é zumbi: a sessão acabou, sem esperar a janela
|
|
795
|
+
recorded = presence.get("cliStart")
|
|
796
|
+
# Um heartbeat FRESCO vence a comparação de start: `cliStart` é gravado como
|
|
797
|
+
# (agora_do_hook - etime) e a tabela recalcula (agora_do_leitor - etime), então um salto de
|
|
798
|
+
# relógio (NTP, suspensão do Mac) maior que a tolerância faria os dois divergirem e mataria
|
|
799
|
+
# uma sessão VIVA. Se ela deu sinal agora, está viva, qualquer que seja a aritmética.
|
|
800
|
+
if exists and age > CLI_REUSE_SECONDS and isinstance(start, (int, float)) \
|
|
801
|
+
and isinstance(recorded, (int, float)) and abs(start - recorded) > CLI_REUSE_SECONDS:
|
|
802
|
+
return None # o número do pid foi reciclado por outro processo; esta sessão morreu
|
|
680
803
|
if age <= cfg["stale_minutes"] * 60:
|
|
681
804
|
return "live"
|
|
682
|
-
presence = presence_of(snap)
|
|
683
805
|
if presence.get("pidStable") and age <= cfg["pid_alive_hours"] * 3600 and pid_alive(presence.get("pid")):
|
|
684
806
|
return "live"
|
|
685
807
|
return None
|
|
686
808
|
|
|
687
809
|
|
|
688
|
-
def live_sessions(home, cfg=None, now=None):
|
|
810
|
+
def live_sessions(home, cfg=None, now=None, table=None):
|
|
689
811
|
"""crow-session/1 snapshots of live sessions of this user, newest first, each with ageSeconds and repo."""
|
|
690
812
|
cfg = cfg or config(home)
|
|
691
813
|
now = now or time.time()
|
|
@@ -709,7 +831,7 @@ def live_sessions(home, cfg=None, now=None):
|
|
|
709
831
|
snap = read_json(entry.path)
|
|
710
832
|
if not isinstance(snap, dict) or snap.get("schema") != SESSION_SCHEMA:
|
|
711
833
|
continue
|
|
712
|
-
if liveness(snap, age, cfg, now) is None:
|
|
834
|
+
if liveness(snap, age, cfg, now, table=table) is None:
|
|
713
835
|
continue
|
|
714
836
|
snap["ageSeconds"] = max(0, int(age))
|
|
715
837
|
presence = presence_of(snap)
|
|
@@ -1250,11 +1372,11 @@ def relations(me, other, now, cfg):
|
|
|
1250
1372
|
return rel
|
|
1251
1373
|
|
|
1252
1374
|
|
|
1253
|
-
def overview(home, me_key=None, me=None, cwd=None, now=None, cfg=None):
|
|
1375
|
+
def overview(home, me_key=None, me=None, cwd=None, now=None, cfg=None, table=None):
|
|
1254
1376
|
"""crow-broadcast/1: every live session with its relation to me, plus active claims."""
|
|
1255
1377
|
cfg = cfg or config(home)
|
|
1256
1378
|
now = now or time.time()
|
|
1257
|
-
live = live_sessions(home, cfg, now)
|
|
1379
|
+
live = live_sessions(home, cfg, now, table=table)
|
|
1258
1380
|
if me is None:
|
|
1259
1381
|
me = next((s for s in live if s.get("key") == me_key), None) if me_key else None
|
|
1260
1382
|
if me is None and cwd:
|
package/crow_skills/cli.py
CHANGED
|
@@ -18,7 +18,8 @@ from . import projection
|
|
|
18
18
|
from . import resources
|
|
19
19
|
from . import machine
|
|
20
20
|
from . import ui
|
|
21
|
-
from .agents import AGENTS, destinations, inventory, legacy_shared_dir
|
|
21
|
+
from .agents import (AGENTS, destinations, inventory, legacy_shared_dir,
|
|
22
|
+
refuse_implausible_destination, refuse_store_as_cli_dir)
|
|
22
23
|
from .source import CrowError, Source, valid_name
|
|
23
24
|
from .storage import Store, checked
|
|
24
25
|
|
|
@@ -261,7 +262,11 @@ def scope(args, home, interactive=False, read=False):
|
|
|
261
262
|
agents = ",".join(AGENTS)
|
|
262
263
|
elif interactive:
|
|
263
264
|
agents = prompt("Agentes separados por vírgula (" + ",".join(AGENTS) + "):")
|
|
264
|
-
|
|
265
|
+
targets = destinations(agents, home, project, custom)
|
|
266
|
+
if not read:
|
|
267
|
+
refuse_store_as_cli_dir(targets, home)
|
|
268
|
+
refuse_implausible_destination(targets, home)
|
|
269
|
+
return targets
|
|
265
270
|
|
|
266
271
|
|
|
267
272
|
def authorize(args, interactive, summary):
|
|
@@ -357,6 +362,22 @@ def guide_sync(args, home, store, command, targets, plan, source):
|
|
|
357
362
|
report["skipped"].append({"agent": agent, "reason": "crow-behaviour ausente; guia mantido. Confira crow-skills guide status."})
|
|
358
363
|
elif not record and command != "uninstall":
|
|
359
364
|
report["skipped"].append({"agent": agent, "reason": "crow-behaviour não ficará instalada para " + agent + "; guia não aplicado."})
|
|
365
|
+
# Um guia que não pode ser gravado não derruba a instalação das skills. Desde que o guia também
|
|
366
|
+
# resolve o caminho físico, o caso que sobra aqui é a área PROTEGIDA (prompt compartilhado,
|
|
367
|
+
# config do Uniflow), que `guide.protect()` recusa com protected_path: aquele agente entra em
|
|
368
|
+
# "skipped", com o motivo, e o resto segue.
|
|
369
|
+
for agent in list(actions):
|
|
370
|
+
mechanism = guide.MECHANISMS.get(agent)
|
|
371
|
+
if not mechanism:
|
|
372
|
+
continue
|
|
373
|
+
try:
|
|
374
|
+
guide.protect(Path(home) / mechanism["path"], home)
|
|
375
|
+
except CrowError as exc:
|
|
376
|
+
actions.pop(agent, None)
|
|
377
|
+
report["skipped"].append({"agent": agent, "code": exc.code,
|
|
378
|
+
"reason": str(exc) + " O guia não foi aplicado; as skills seguem. "
|
|
379
|
+
"Grave o guia você mesmo no caminho físico, ou use --no-guide "
|
|
380
|
+
"para não voltar a tentar."})
|
|
360
381
|
if not actions:
|
|
361
382
|
return report, None
|
|
362
383
|
if source is None and "install" in actions.values():
|
|
@@ -573,7 +594,9 @@ def broadcast_view(args, home, owner=None):
|
|
|
573
594
|
cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
|
|
574
595
|
owner = owner if owner is not None else broadcast_owner(args, home, cwd)
|
|
575
596
|
me_key = owner["key"] if owner else None
|
|
576
|
-
|
|
597
|
+
# Aqui a identidade forte é de graça: este caminho já é um comando do usuário, não o hook.
|
|
598
|
+
# Um único ps para a máquina distingue "pid vivo" de "número reciclado" e derruba zumbi.
|
|
599
|
+
view = broadcast.overview(home, me_key, cwd=cwd, table=broadcast.process_table())
|
|
577
600
|
if view["me"] is not None and view["me"].get("cwd") is None:
|
|
578
601
|
view["me"]["cwd"] = cwd
|
|
579
602
|
view["owner"] = owner
|
package/crow_skills/guide.py
CHANGED
|
@@ -119,8 +119,16 @@ def shielded(path, home):
|
|
|
119
119
|
|
|
120
120
|
|
|
121
121
|
def protect(path, home):
|
|
122
|
-
"""Refuse
|
|
123
|
-
|
|
122
|
+
"""Refuse shared prompts and anything inside their targets, on the PHYSICAL path.
|
|
123
|
+
|
|
124
|
+
Resolve first (0.16.0, decisão do dono): o arquivo de guia e as integrações de um CLI cujo
|
|
125
|
+
diretório vive em dotfiles ficavam de fora, e o CLI terminava instalado sem guia, sem hooks e
|
|
126
|
+
sem /crow — a skill ficava lá sem o agente carregá-la por iniciativa própria. `checked()`
|
|
127
|
+
continua recusando escrever ATRAVÉS de symlink, agora sobre o caminho real, e `shielded()`
|
|
128
|
+
passa a comparar o destino real, o que torna a proteção de prompt compartilhado mais precisa.
|
|
129
|
+
"""
|
|
130
|
+
from .agents import physical
|
|
131
|
+
path = checked(physical(path))
|
|
124
132
|
if shielded(path, home):
|
|
125
133
|
raise CrowError("protected_path", "Arquivo de prompt compartilhado (ou alvo dele) não é gravado: " + str(path))
|
|
126
134
|
return path
|
|
@@ -132,7 +140,9 @@ def protect_skill(path, home):
|
|
|
132
140
|
config (config.json, prompts, backups) stay protected. The package entry itself may
|
|
133
141
|
be a link Uniflow made; the plan decides about it, never following it."""
|
|
134
142
|
path = Path(os.path.abspath(path))
|
|
135
|
-
|
|
143
|
+
from .agents import physical
|
|
144
|
+
# Mesmo motivo: o store em dotfiles resolve, e a comparação crua nunca batia.
|
|
145
|
+
if physical(path.parent) == physical(uniflow_store(home)) and NAME.fullmatch(path.name):
|
|
136
146
|
checked(path.parent)
|
|
137
147
|
return path
|
|
138
148
|
path = checked(path)
|
package/crow_skills/storage.py
CHANGED
|
@@ -19,6 +19,18 @@ from .agents import uniflow_store
|
|
|
19
19
|
from .source import CrowError, MAX_FILE, MAX_PACKAGE, MAX_FILES, SHA, digest, file_entry, safe_relative, same_source, source_identity, valid_name
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def resolved(path):
|
|
23
|
+
"""checked() sobre o caminho FÍSICO: usado onde o alvo vem GRAVADO numa transação.
|
|
24
|
+
|
|
25
|
+
Um alvo registrado antes de o diretório virar dotfiles continua apontando para o caminho antigo.
|
|
26
|
+
Exigir ali um caminho livre de symlink quebrava rollback e recover e chegava a TRAVAR o perfil
|
|
27
|
+
(recover recusava, rollback dizia fase incompatível, e todo install passava a exigir recovery).
|
|
28
|
+
Resolver primeiro mantém a garantia: o que é escrito continua passando por checked().
|
|
29
|
+
"""
|
|
30
|
+
from .agents import physical
|
|
31
|
+
return checked(physical(path))
|
|
32
|
+
|
|
33
|
+
|
|
22
34
|
def checked(path):
|
|
23
35
|
path = Path(os.path.abspath(Path(path).expanduser()))
|
|
24
36
|
for part in reversed((path, *path.parents)):
|
|
@@ -132,6 +144,22 @@ def validate_entries(entries):
|
|
|
132
144
|
raise CrowError("invalid_state", "Hash/tipo de arquivo inválido no estado.")
|
|
133
145
|
|
|
134
146
|
|
|
147
|
+
def retargeted(state, target):
|
|
148
|
+
"""O manifesto gravado precisa declarar o caminho de HOJE (resolvido), não o de quando a
|
|
149
|
+
transação rodou: `validate_state` exige essa igualdade, e sem isto um rollback restaurava um
|
|
150
|
+
estado que o próprio leitor recusava depois com invalid_state."""
|
|
151
|
+
if not isinstance(state, dict):
|
|
152
|
+
return state
|
|
153
|
+
return dict(state, target=str(resolved(target)))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def same_manifest(a, b):
|
|
157
|
+
"""Dois manifestos descrevem o mesmo conteúdo, ignorando a string do caminho do alvo."""
|
|
158
|
+
if a is None or b is None:
|
|
159
|
+
return a == b
|
|
160
|
+
return {k: v for k, v in a.items() if k != "target"} == {k: v for k, v in b.items() if k != "target"}
|
|
161
|
+
|
|
162
|
+
|
|
135
163
|
def validate_state(data, target):
|
|
136
164
|
try:
|
|
137
165
|
if (data["schema_version"] != 1 or data["target"] != str(target) or
|
|
@@ -153,12 +181,66 @@ class Store:
|
|
|
153
181
|
self.root = self.home / ".local/state/crow-skills"
|
|
154
182
|
|
|
155
183
|
def state_path(self, target):
|
|
156
|
-
key = hashlib.sha256(str(
|
|
184
|
+
key = hashlib.sha256(str(resolved(target)).encode()).hexdigest()
|
|
157
185
|
return self.root / "targets" / key / "state.json"
|
|
158
186
|
|
|
187
|
+
def aliased_state_path(self, target):
|
|
188
|
+
"""Estado gravado sob um APELIDO do mesmo destino (antes da resolução de symlink, 0.16.0).
|
|
189
|
+
|
|
190
|
+
A chave é o sha do caminho; um estado escrito quando ~/.claude ainda era diretório comum
|
|
191
|
+
está sob outra chave e ficaria invisível depois que o usuário move tudo para dotfiles —
|
|
192
|
+
`uninstall` responderia not_managed e a cópia instalada viraria órfã. Varre os destinos
|
|
193
|
+
conhecidos (poucos) e devolve o que resolve para o MESMO caminho físico."""
|
|
194
|
+
from .agents import physical
|
|
195
|
+
directory = self.root / "targets"
|
|
196
|
+
try:
|
|
197
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
198
|
+
return None
|
|
199
|
+
entries = list(os.scandir(directory))
|
|
200
|
+
except OSError:
|
|
201
|
+
return None
|
|
202
|
+
wanted = physical(target)
|
|
203
|
+
found = []
|
|
204
|
+
for entry in entries:
|
|
205
|
+
candidate = Path(entry.path) / "state.json"
|
|
206
|
+
try:
|
|
207
|
+
data = read_json(candidate)
|
|
208
|
+
except (CrowError, OSError, ValueError):
|
|
209
|
+
continue
|
|
210
|
+
recorded = data.get("target") if isinstance(data, dict) else None
|
|
211
|
+
if isinstance(recorded, str) and recorded != str(target) and physical(recorded) == wanted:
|
|
212
|
+
found.append(candidate)
|
|
213
|
+
# Mais de um apelido para o MESMO caminho físico é ambíguo: escolher o primeiro do scandir
|
|
214
|
+
# (ordem arbitrária de hash) podia reportar uma skill gerenciada como `unmanaged` e, com
|
|
215
|
+
# --replace, sobrescrevê-la como se fosse lixo alheio — exatamente o dano que o apelido
|
|
216
|
+
# existe para evitar. Sem resposta única, nenhuma resposta.
|
|
217
|
+
return found[0] if len(found) == 1 else None
|
|
218
|
+
|
|
219
|
+
def retire_alias(self, target):
|
|
220
|
+
"""Retira o estado gravado sob um APELIDO deste destino, depois de gravar o canônico.
|
|
221
|
+
|
|
222
|
+
Sem isto o registro antigo ficava para trás divergente e voltava a mandar se o diretório
|
|
223
|
+
deixasse de ser link: uma skill removida ressuscitava como `missing`, com a revisão errada,
|
|
224
|
+
deixando o doctor insalubre e o update em source_mismatch. Renomeia para `.superseded`
|
|
225
|
+
em vez de apagar, para que nada se perca se o diagnóstico precisar do histórico.
|
|
226
|
+
"""
|
|
227
|
+
alias = self.aliased_state_path(target)
|
|
228
|
+
if alias is None:
|
|
229
|
+
return
|
|
230
|
+
try:
|
|
231
|
+
alias.replace(alias.with_suffix(".superseded.json"))
|
|
232
|
+
except OSError:
|
|
233
|
+
pass # nunca derruba a mutação por causa da aposentadoria do apelido
|
|
234
|
+
|
|
159
235
|
def state(self, target):
|
|
160
|
-
target =
|
|
236
|
+
target = resolved(target)
|
|
161
237
|
path = checked(self.state_path(target))
|
|
238
|
+
if not path.exists():
|
|
239
|
+
alias = self.aliased_state_path(target)
|
|
240
|
+
if alias is not None:
|
|
241
|
+
data = read_json(alias)
|
|
242
|
+
# Reescreve o alvo para o caminho físico; a próxima mutação grava sob a chave nova.
|
|
243
|
+
return validate_state(dict(data, target=str(target)), target)
|
|
162
244
|
if not path.exists():
|
|
163
245
|
return {"schema_version": 1, "target": str(target), "installed": {}}
|
|
164
246
|
return validate_state(read_json(path), target)
|
|
@@ -183,7 +265,11 @@ class Store:
|
|
|
183
265
|
if not isinstance(targets, dict) or set(targets) != set(data["after"]):
|
|
184
266
|
raise ValueError("targets")
|
|
185
267
|
for target, before in targets.items():
|
|
186
|
-
|
|
268
|
+
# Absoluto e normalizado, mas NÃO exigimos que continue livre de symlink: o destino
|
|
269
|
+
# pode ter virado dotfiles depois desta transação, e toda ESCRITA ainda passa por
|
|
270
|
+
# checked(). Exigir aqui derrubava status, doctor, install, uninstall e recover de
|
|
271
|
+
# TODOS os agentes por causa de um journal antigo.
|
|
272
|
+
if target != os.path.abspath(target):
|
|
187
273
|
raise ValueError("path")
|
|
188
274
|
validate_state(before, target)
|
|
189
275
|
validate_state(data["after"][target], target)
|
|
@@ -223,7 +309,7 @@ class Store:
|
|
|
223
309
|
|
|
224
310
|
def work(self, target, txid):
|
|
225
311
|
from .guide import shielded
|
|
226
|
-
target =
|
|
312
|
+
target = resolved(target)
|
|
227
313
|
key = hashlib.sha256(str(target).encode()).hexdigest()[:20]
|
|
228
314
|
# Beside the discovery dir, unless that parent is protected (the Uniflow
|
|
229
315
|
# config): then staging/backups stay in the private state root instead.
|
|
@@ -234,7 +320,7 @@ class Store:
|
|
|
234
320
|
"""Backups of a transaction, wherever the version that made it put them: older
|
|
235
321
|
transactions kept them beside the target even where that is now protected."""
|
|
236
322
|
primary = self.work(target, txid)
|
|
237
|
-
target =
|
|
323
|
+
target = resolved(target)
|
|
238
324
|
key = hashlib.sha256(str(target).encode()).hexdigest()[:20]
|
|
239
325
|
for candidate in (primary, target.parent / ".crow-skills" / key / txid, self.root / "work" / key / txid):
|
|
240
326
|
if os.path.lexists(candidate):
|
|
@@ -284,7 +370,13 @@ class Store:
|
|
|
284
370
|
"crow-skills uninstall " + name + " --dest " + str(target) + " --yes e, se for para o "
|
|
285
371
|
"Uniflow, reinstale com --agents uniflow --global.") from exc
|
|
286
372
|
# Only the uniflow agent replaces Uniflow's import links; --dest keeps refusing links.
|
|
287
|
-
|
|
373
|
+
# physical() e não resolved(): plan() tem um local chamado `resolved` mais abaixo, que
|
|
374
|
+
# torna o nome local em toda a função e faria esta linha estourar UnboundLocalError.
|
|
375
|
+
# O store do Uniflow em dotfiles tem o caminho resolvido, e comparar com o não resolvido
|
|
376
|
+
# deixava o store inteiro não gerenciável (lacuna anterior à 0.16.0).
|
|
377
|
+
from .agents import physical as _physical
|
|
378
|
+
store_target = "uniflow" in target_info["agents"] and \
|
|
379
|
+
str(target) == _physical(uniflow_store(self.home))
|
|
288
380
|
before[key] = state
|
|
289
381
|
after[key] = json.loads(json.dumps(state))
|
|
290
382
|
selected = list(names) if names else list(state["installed"])
|
|
@@ -397,7 +489,8 @@ class Store:
|
|
|
397
489
|
if item["new"] is not None:
|
|
398
490
|
os.replace(work / "new" / item["name"], dest)
|
|
399
491
|
for target, state in data["after"].items():
|
|
400
|
-
atomic_json(self.state_path(target), state)
|
|
492
|
+
atomic_json(self.state_path(target), retargeted(state, target))
|
|
493
|
+
self.retire_alias(target)
|
|
401
494
|
data["phase"] = "committed"
|
|
402
495
|
atomic_json(journal, data)
|
|
403
496
|
except (Exception, KeyboardInterrupt):
|
|
@@ -423,10 +516,14 @@ class Store:
|
|
|
423
516
|
operations = []
|
|
424
517
|
for target, before in data["before"].items():
|
|
425
518
|
current = self.state(target)
|
|
426
|
-
|
|
519
|
+
# O campo "target" dentro do manifesto guarda o caminho de QUANDO a transação rodou; se
|
|
520
|
+
# o diretório virou dotfiles depois, ele difere do caminho resolvido de hoje e as duas
|
|
521
|
+
# estruturas nunca batem, recusando um rollback perfeitamente válido. A comparação é
|
|
522
|
+
# sobre o CONTEÚDO (as skills instaladas), não sobre a string do caminho.
|
|
523
|
+
if not same_manifest(current, before) and not same_manifest(current, data["after"][target]):
|
|
427
524
|
raise CrowError("recovery_conflict", "Manifesto mudou após a transação; recuperação recusada.")
|
|
428
525
|
for item in reversed(data["items"]):
|
|
429
|
-
dest =
|
|
526
|
+
dest = resolved(Path(item["target"])) / item["name"]
|
|
430
527
|
old = self.existing_work(item["target"], data["id"]) / "old" / item["name"]
|
|
431
528
|
if item.get("link") is not None:
|
|
432
529
|
if same_link(old, item["link"]):
|
|
@@ -462,7 +559,8 @@ class Store:
|
|
|
462
559
|
if backup is not None:
|
|
463
560
|
os.replace(backup, dest)
|
|
464
561
|
for target, state in data["before"].items():
|
|
465
|
-
atomic_json(self.state_path(target), state)
|
|
562
|
+
atomic_json(self.state_path(target), retargeted(state, target))
|
|
563
|
+
self.retire_alias(target)
|
|
466
564
|
data["phase"] = "rolled_back"
|
|
467
565
|
atomic_json(self.root / "transactions" / (data["id"] + ".json"), data)
|
|
468
566
|
|
|
@@ -479,7 +577,10 @@ class Store:
|
|
|
479
577
|
return rollback(self, data, self.home, recovery)
|
|
480
578
|
if not recovery:
|
|
481
579
|
for target, state in data["after"].items():
|
|
482
|
-
|
|
580
|
+
# same_manifest, e não ==: o campo "target" do manifesto guarda o caminho de
|
|
581
|
+
# quando a transação rodou, e um diretório que virou dotfiles depois faria toda
|
|
582
|
+
# comparação falhar, recusando um rollback válido por diferença de string.
|
|
583
|
+
if not same_manifest(self.state(target), state):
|
|
483
584
|
raise CrowError("rollback_conflict", "Há mudanças posteriores; rollback antigo recusado.")
|
|
484
585
|
self._restore(data)
|
|
485
586
|
|
|
@@ -506,7 +607,11 @@ class Store:
|
|
|
506
607
|
continue
|
|
507
608
|
record = state["installed"].get(name)
|
|
508
609
|
link = target / name
|
|
509
|
-
|
|
610
|
+
from .agents import physical as _physical # local `resolved` nesta função sombreia o helper
|
|
611
|
+
# Path(), não a string crua: `physical()` devolve str, e aqui o valor é comparado
|
|
612
|
+
# com um Path (`target == store`, que seria SEMPRE falso, matando em silêncio o
|
|
613
|
+
# ramo de status external_link) e usado como caminho (`store / name`, TypeError).
|
|
614
|
+
store = Path(_physical(uniflow_store(self.home)))
|
|
510
615
|
if link.is_symlink() and target == store:
|
|
511
616
|
resolved = os.path.realpath(link)
|
|
512
617
|
managed = self.managed_target(resolved, name)
|
package/docs/cli.md
CHANGED
|
@@ -191,6 +191,18 @@ Erros retornam `ok:false` e `error.code/message`. Saídas reais incluem alteraç
|
|
|
191
191
|
|
|
192
192
|
Cada adaptador grava no diretório PRÓPRIO do seu CLI: o crow-skills atua em cada CLI diretamente e não mantém pasta compartilhada de skills. Até a 0.14.0 o codex era a exceção, instalando em `~/.agents/skills`, o que fazia o crow-skills agir sobre um diretório em vez de sobre um agente: aparecia um agente `shared` que não é CLI nenhum, e o Codex, que varre as duas raízes, listava a mesma skill duas vezes. O adaptador `shared` foi removido e `~/.agents/skills` virou apenas legado: nada é instalado lá, e o que ficou de versões anteriores é apontado como duplicata para você remover (veja [Codex e a projeção do Uniflow](#codex-e-a-projeção-do-uniflow)). Para gravar num diretório fora dos adaptadores, use `--dest` explicitamente.
|
|
193
193
|
|
|
194
|
+
#### Diretório de skills em symlink (dotfiles)
|
|
195
|
+
|
|
196
|
+
Desde a 0.16.0 o destino de cada adaptador é resolvido para o caminho FÍSICO (`agents.physical`, via `os.path.realpath`): `~/.codex -> /dotfiles/codex` instala em `/dotfiles/codex/skills`, e o symlink é preservado. Vale para os cinco CLIs e também para `--dest`. Antes isso era recusado com `unsafe_destination` em todos eles.
|
|
197
|
+
|
|
198
|
+
O controle de segurança não foi enfraquecido, foi deslocado: `storage.checked()` continua recusando escrever ATRAVÉS de um symlink, e agora valida o destino real, o que é mais preciso. As proteções por LOCALIZAÇÃO continuam valendo depois da resolução: um diretório de skills que resolve para dentro de `~/.config/uniflow` é recusado com `protected_path`, sem tocar a config.
|
|
199
|
+
|
|
200
|
+
Duas consequências que vale conhecer:
|
|
201
|
+
|
|
202
|
+
- **O arquivo de prompt do agente não é resolvido.** `CLAUDE.md`, `AGENTS.md` e `rules/crow-skills.md` seguem recusados quando o caminho passa por symlink, porque gravar ali reescreveria um arquivo dentro do seu repositório de dotfiles. Isso não derruba mais o comando: o agente aparece em `guides.skipped` com `code: "unsafe_destination"` e as skills são instaladas. Para ter o guia nesse caso, grave-o você mesmo no caminho físico ou use `--no-guide` para não voltar a tentar.
|
|
203
|
+
- **Apontar o diretório de um CLI exatamente para o store do Uniflow é RECUSADO numa mutação** (`protected_path`, com a dica de usar `--agents uniflow`). Gravar no store vale para todos os CLIs que o Uniflow projeta, não só para o selecionado, e a resolução tornou esse caminho alcançável a partir da seleção de um CLI. A checagem roda só na mutação: `destinations()` é usado por `status`, `doctor` e pelos avisos do uniflow para inspecionar o destino de outro agente, e recusar ali derrubaria leitura.
|
|
204
|
+
- **As integrações (status line, hooks, comando) também passam por `protect()`**, então num diretório de agente em symlink elas são recusadas junto com o guia e aparecem em `integrations.skipped`. Um diretório `.crow-skills` de transações/backup aparece dentro do caminho físico (o repositório de dotfiles) e não é podado depois do commit.
|
|
205
|
+
|
|
194
206
|
`agents` só verifica PATH e caminhos, sem iniciar agente/LLM. `status`/`doctor` conferem arquivos e estado de instalação, não provam carregamento, ativação, independência dos workers ou execução correta da skill. Gemini oferece `/skills reload`; para outros hosts, confira sua lista/reload ou reinicie a sessão conforme a versão. Nenhum system prompt base é alterado; o guia abaixo e os hooks do modo crow, aplicados por padrão em `--global` e desligáveis com `--no-guide` e `--no-integrations`, são aditivos: só informam ao agente o modo escolhido pelo usuário.
|
|
195
207
|
|
|
196
208
|
### Uniflow
|
|
@@ -216,6 +228,12 @@ O Uniflow guarda as skills num store próprio e cria, ao subir cada CLI, links d
|
|
|
216
228
|
- Cópias nativas duplicadas: se claude, gemini ou devin já têm um pacote crow de mesmo nome, o Uniflow não cria o link para ele e a cópia nativa continua valendo ali, possivelmente em outra revisão (`effect: skipped`). O codex se comporta igual desde a 0.15.0, por instalar no diretório dele (`effect: skipped`); antes ele instalava na pasta compartilhada e o Codex via a mesma skill duas vezes. `install`/`update` com `uniflow` listam essas cópias em `uniflow.native_duplicates` (diretórios que não puderam ser lidos, como um `~/.claude` symlink de dotfiles, vão para `uniflow.uninspected` sem derrubar o comando) e sugerem em `uniflow.suggested_command` o `uninstall` explícito; nada é removido sem esse comando. Remover a cópia nativa da crow-behaviour remove também o guia daquele agente, que deixa de valer fora do Uniflow.
|
|
217
229
|
- Links projetados: em `status`/`doctor`, um link do Uniflow para o store num diretório de agente aparece como `uniflow_link`, e não deixa o doctor `healthy:false`.
|
|
218
230
|
|
|
231
|
+
#### Estado gravado sob um apelido do destino
|
|
232
|
+
|
|
233
|
+
A chave do estado é o caminho FÍSICO. Um estado escrito quando `~/.claude` ainda era diretório comum fica sob outra chave, e `Store.state()` o encontra varrendo os destinos conhecidos (`aliased_state_path`) para não perder a instalação quando você move tudo para dotfiles. Depois de gravar o estado canônico, a mutação APOSENTA o apelido, renomeando-o para `.superseded.json` em vez de apagar. Sem essa aposentadoria o registro antigo ficava divergente e voltava a mandar caso o diretório deixasse de ser link: uma skill removida ressuscitava como `missing`, com a revisão errada, e o `update` passava a recusar com `source_mismatch`. Dois apelidos para o mesmo caminho físico são ambíguos e nenhum é usado.
|
|
234
|
+
|
|
235
|
+
Consequência a conhecer, se você DESFIZER o link depois de ter mexido pelo link: o estado canônico ficou sob o caminho físico de então (o dos dotfiles) e o apelido antigo foi aposentado, então o novo local não tem registro e as skills aparecem como `unmanaged`, com o `doctor` saudável. É a resposta honesta — não existe registro para aquele caminho —, e `install --replace` ou `setup` readotam o que está lá. O comportamento anterior era pior: o registro antigo ressuscitava e reportava uma skill já removida como `missing`, com a revisão errada, deixando o `doctor` insalubre e o `update` em `source_mismatch`.
|
|
236
|
+
|
|
219
237
|
#### Exercitar um hook sem efeito colateral (`--probe`)
|
|
220
238
|
|
|
221
239
|
`crow_hook.py hook --host <host> --event <evento> --probe` calcula exatamente o que o hook entregaria naquele evento — modo crow, aviso de memória e o bloco do crow-broadcast — e **não grava nada**: nenhum evento no barramento, nenhum snapshot de sessão, nenhuma presença, nenhuma escrita de modo. É o que usar para conferir se o hook de um CLI está cabeado e funcionando.
|
|
@@ -232,7 +250,7 @@ O Codex lê duas raízes de skills do usuário: `$CODEX_HOME/skills` (padrão `~
|
|
|
232
250
|
- A crow-behaviour continua nativa onde o Uniflow não a projeta, e o guia, as integrações e os hooks do Codex seguem funcionando; quando ela é projetada, o guia conta a projeção.
|
|
233
251
|
- Migração da pasta compartilhada antiga: em `setup`, `upgrade` e no autoupdate, depois de instalar no diretório do Codex, o crow-skills remove de `~/.agents/skills` as skills que ELE instalou lá e que estão íntegras, pelo `uninstall` normal (`--dest ~/.agents/skills`, com backup, transação e `rollback`); o resumo do agente traz isso em `legacy_shared`. Isso cobre também uma skill que o Uniflow não projeta (a própria crow-behaviour, tipicamente), que não apareceria como duplicata e ficaria lá para sempre. Uma cópia editada, ou também registrada para outro agente daquele diretório, é mantida e reportada em `kept` com o comando. Nada que não tenha sido instalado pelo crow-skills é tocado, e uma falha na migração nunca derruba o resultado do agente (vira `legacy_shared.state: warn`).
|
|
234
252
|
- A cópia legada só sai quando aquele nome REALMENTE funciona no diretório do Codex — instalação íntegra nossa, ou link saudável do Uniflow. Se a entrada do store do Uniflow está quebrada, o link ocupa o nome sem funcionar e a cópia legada é a única skill que o Codex tem: nesse caso ela é mantida e reportada (`legacy_shared_without_replacement`), porque removê-la tiraria a skill em vez de migrá-la. O mesmo vale quando o nome no diretório do Codex é um apelido apontando para a própria cópia legada: remover o alvo deixaria um link pendurado. Cada nome recebe UMA razão em `kept`: uma razão mais específica já registrada para ele (projeção quebrada, destino inseguro) prevalece.
|
|
235
|
-
- **Mudanças de comportamento do destino do codex (0.15.0).** Com o destino no diretório próprio do Codex,
|
|
253
|
+
- **Mudanças de comportamento do destino do codex (0.15.0).** Com o destino no diretório próprio do Codex, uma situação que antes não afetava o codex passa a afetá-lo, exatamente como já valia para claude, gemini e devin: (a) a partir da 0.16.0 um `~/.codex`/`$CODEX_HOME` em symlink é RESOLVIDO para o caminho físico (veja a seção de dotfiles acima); na 0.15.0 ele era recusado com `unsafe_destination`; (b) se houver um link de qualquer tipo ocupando o nome no diretório (inclusive o apelido para a pasta legada), aquele nome não pode ser gerenciado: ele é reportado com status `unsafe` e o comando sai com 1, sem alterar o link. Não é uma regra nova do codex, é a mesma regra dos outros CLIs passando a valer para ele.
|
|
236
254
|
- **`$CODEX_HOME` relativo é ignorado.** Um valor relativo seria resolvido contra o diretório de trabalho DESTE processo, que não é o do Codex, então o destino dependeria de onde o comando foi rodado e poderia cair dentro de um repositório seu. Só valor absoluto (ou com `~`) é honrado; caso contrário vale `<home>/.codex`.
|
|
237
255
|
- **Fallback perdido quando a entrada do store do Uniflow está quebrada.** Até a 0.14.0 o codex tinha duas raízes, então uma entrada quebrada no store ainda deixava uma cópia funcional na pasta compartilhada. Agora o link do Uniflow ocupa o nome no diretório do Codex: a entrada quebrada é reportada (`uniflow_link_modified`, com o comando que conserta o store) e a skill fica sem versão funcional até você consertar. A cópia legada, se existir, NÃO é removida nesse caso — é justamente o que o guarda de substituição protege.
|
|
238
256
|
- **Aviso `codex_legacy_shared` em `status`/`doctor`.** Numa máquina sem Uniflow nada do que ficou na pasta compartilhada duplica uma projeção, então o aviso de duplicatas não apareceria e uma instalação feita pela 0.14.0 ficaria invisível, ainda carregada pelo Codex. Esse aviso novo lista essas skills, com o caminho e o comando (`setup --agents codex --yes`, que migra, ou `uninstall … --dest <pasta legada>`).
|
package/package.json
CHANGED
package/release.json
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"crow_skills/__init__.py": {
|
|
19
19
|
"executable": false,
|
|
20
20
|
"kind": "file",
|
|
21
|
-
"sha256": "
|
|
21
|
+
"sha256": "fb5a2f995f5007c08a1d21d9853763865628ac218330da800ef7b3461282a626"
|
|
22
22
|
},
|
|
23
23
|
"crow_skills/__main__.py": {
|
|
24
24
|
"executable": false,
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"crow_skills/agents.py": {
|
|
29
29
|
"executable": false,
|
|
30
30
|
"kind": "file",
|
|
31
|
-
"sha256": "
|
|
31
|
+
"sha256": "903750fa57f9aa3cba686bcbd3775881493275b1e5aff86c5e57035d2144c7f1"
|
|
32
32
|
},
|
|
33
33
|
"crow_skills/autoupdate.py": {
|
|
34
34
|
"executable": false,
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"crow_skills/broadcast.py": {
|
|
39
39
|
"executable": false,
|
|
40
40
|
"kind": "file",
|
|
41
|
-
"sha256": "
|
|
41
|
+
"sha256": "d886806e138953dfe47bd19c8f12b4267f5137e510346081c2e3fb15e4b7a8fb"
|
|
42
42
|
},
|
|
43
43
|
"crow_skills/cli.py": {
|
|
44
44
|
"executable": false,
|
|
45
45
|
"kind": "file",
|
|
46
|
-
"sha256": "
|
|
46
|
+
"sha256": "3182cabd5cf63e780e37f0ae0ef5b2b1090d91debaf90d54765dc80a08e6f864"
|
|
47
47
|
},
|
|
48
48
|
"crow_skills/events.py": {
|
|
49
49
|
"executable": false,
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"crow_skills/guide.py": {
|
|
59
59
|
"executable": false,
|
|
60
60
|
"kind": "file",
|
|
61
|
-
"sha256": "
|
|
61
|
+
"sha256": "0b5ca9d1629bcdc88e30f42b14b1189f41e9417b46c16771c339fe3ffc09dece"
|
|
62
62
|
},
|
|
63
63
|
"crow_skills/hook.py": {
|
|
64
64
|
"executable": false,
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"crow_skills/storage.py": {
|
|
104
104
|
"executable": false,
|
|
105
105
|
"kind": "file",
|
|
106
|
-
"sha256": "
|
|
106
|
+
"sha256": "2e276f27c25256508670afcd1d6da38811819351dee45e42f4098090a00d8832"
|
|
107
107
|
},
|
|
108
108
|
"crow_skills/trust.py": {
|
|
109
109
|
"executable": false,
|
|
@@ -123,12 +123,12 @@
|
|
|
123
123
|
"docs/cli.md": {
|
|
124
124
|
"executable": false,
|
|
125
125
|
"kind": "file",
|
|
126
|
-
"sha256": "
|
|
126
|
+
"sha256": "84bc06ac93083a18d81d202409dfe26202feb20d4d6cab7a46520badd2b13a13"
|
|
127
127
|
},
|
|
128
128
|
"package.json": {
|
|
129
129
|
"executable": false,
|
|
130
130
|
"kind": "file",
|
|
131
|
-
"sha256": "
|
|
131
|
+
"sha256": "774b17857c2e1701e444296d522e1d36d3a1b4915b63bb86d3bc45d405a51f73"
|
|
132
132
|
},
|
|
133
133
|
"skills/crow-accessibility/SKILL.md": {
|
|
134
134
|
"executable": false,
|
|
@@ -716,7 +716,7 @@
|
|
|
716
716
|
"sha256": "e37132ef8b060548191fbf14a5a18a9dd3acd84ea9a35e802174455b9e374d3d"
|
|
717
717
|
}
|
|
718
718
|
},
|
|
719
|
-
"revision": "
|
|
719
|
+
"revision": "a0d9e9a088c074f4422b2181ac0629631dbea321",
|
|
720
720
|
"schema_version": 1,
|
|
721
|
-
"version": "0.
|
|
721
|
+
"version": "0.16.1"
|
|
722
722
|
}
|