@andespindola/crow-skills 0.15.0 → 0.16.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/crow_skills/__init__.py +1 -1
- package/crow_skills/agents.py +105 -4
- package/crow_skills/broadcast.py +138 -20
- package/crow_skills/cli.py +26 -3
- package/crow_skills/guide.py +10 -2
- package/crow_skills/storage.py +87 -10
- package/docs/cli.md +13 -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,101 @@ 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
|
+
raise CrowError("protected_path", "O destino de " + ",".join(others) + " resolve para o store do "
|
|
216
|
+
"Uniflow (" + store + "), que vale para todos os CLIs que ele projeta, não só para "
|
|
217
|
+
+ ",".join(others) + ". Use --agents uniflow para gravar no store.")
|
|
218
|
+
|
|
219
|
+
|
|
119
220
|
def inventory(home):
|
|
120
221
|
rows = []
|
|
121
222
|
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,93 @@ 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
|
+
if exists and isinstance(start, (int, float)) and isinstance(recorded, (int, float)) \
|
|
797
|
+
and abs(start - recorded) > CLI_REUSE_SECONDS:
|
|
798
|
+
return None # o número do pid foi reciclado por outro processo; esta sessão morreu
|
|
680
799
|
if age <= cfg["stale_minutes"] * 60:
|
|
681
800
|
return "live"
|
|
682
|
-
presence = presence_of(snap)
|
|
683
801
|
if presence.get("pidStable") and age <= cfg["pid_alive_hours"] * 3600 and pid_alive(presence.get("pid")):
|
|
684
802
|
return "live"
|
|
685
803
|
return None
|
|
686
804
|
|
|
687
805
|
|
|
688
|
-
def live_sessions(home, cfg=None, now=None):
|
|
806
|
+
def live_sessions(home, cfg=None, now=None, table=None):
|
|
689
807
|
"""crow-session/1 snapshots of live sessions of this user, newest first, each with ageSeconds and repo."""
|
|
690
808
|
cfg = cfg or config(home)
|
|
691
809
|
now = now or time.time()
|
|
@@ -709,7 +827,7 @@ def live_sessions(home, cfg=None, now=None):
|
|
|
709
827
|
snap = read_json(entry.path)
|
|
710
828
|
if not isinstance(snap, dict) or snap.get("schema") != SESSION_SCHEMA:
|
|
711
829
|
continue
|
|
712
|
-
if liveness(snap, age, cfg, now) is None:
|
|
830
|
+
if liveness(snap, age, cfg, now, table=table) is None:
|
|
713
831
|
continue
|
|
714
832
|
snap["ageSeconds"] = max(0, int(age))
|
|
715
833
|
presence = presence_of(snap)
|
|
@@ -1250,11 +1368,11 @@ def relations(me, other, now, cfg):
|
|
|
1250
1368
|
return rel
|
|
1251
1369
|
|
|
1252
1370
|
|
|
1253
|
-
def overview(home, me_key=None, me=None, cwd=None, now=None, cfg=None):
|
|
1371
|
+
def overview(home, me_key=None, me=None, cwd=None, now=None, cfg=None, table=None):
|
|
1254
1372
|
"""crow-broadcast/1: every live session with its relation to me, plus active claims."""
|
|
1255
1373
|
cfg = cfg or config(home)
|
|
1256
1374
|
now = now or time.time()
|
|
1257
|
-
live = live_sessions(home, cfg, now)
|
|
1375
|
+
live = live_sessions(home, cfg, now, table=table)
|
|
1258
1376
|
if me is None:
|
|
1259
1377
|
me = next((s for s in live if s.get("key") == me_key), None) if me_key else None
|
|
1260
1378
|
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
|
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,50 @@ 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
|
+
|
|
159
219
|
def state(self, target):
|
|
160
|
-
target =
|
|
220
|
+
target = resolved(target)
|
|
161
221
|
path = checked(self.state_path(target))
|
|
222
|
+
if not path.exists():
|
|
223
|
+
alias = self.aliased_state_path(target)
|
|
224
|
+
if alias is not None:
|
|
225
|
+
data = read_json(alias)
|
|
226
|
+
# Reescreve o alvo para o caminho físico; a próxima mutação grava sob a chave nova.
|
|
227
|
+
return validate_state(dict(data, target=str(target)), target)
|
|
162
228
|
if not path.exists():
|
|
163
229
|
return {"schema_version": 1, "target": str(target), "installed": {}}
|
|
164
230
|
return validate_state(read_json(path), target)
|
|
@@ -183,7 +249,11 @@ class Store:
|
|
|
183
249
|
if not isinstance(targets, dict) or set(targets) != set(data["after"]):
|
|
184
250
|
raise ValueError("targets")
|
|
185
251
|
for target, before in targets.items():
|
|
186
|
-
|
|
252
|
+
# Absoluto e normalizado, mas NÃO exigimos que continue livre de symlink: o destino
|
|
253
|
+
# pode ter virado dotfiles depois desta transação, e toda ESCRITA ainda passa por
|
|
254
|
+
# checked(). Exigir aqui derrubava status, doctor, install, uninstall e recover de
|
|
255
|
+
# TODOS os agentes por causa de um journal antigo.
|
|
256
|
+
if target != os.path.abspath(target):
|
|
187
257
|
raise ValueError("path")
|
|
188
258
|
validate_state(before, target)
|
|
189
259
|
validate_state(data["after"][target], target)
|
|
@@ -223,7 +293,7 @@ class Store:
|
|
|
223
293
|
|
|
224
294
|
def work(self, target, txid):
|
|
225
295
|
from .guide import shielded
|
|
226
|
-
target =
|
|
296
|
+
target = resolved(target)
|
|
227
297
|
key = hashlib.sha256(str(target).encode()).hexdigest()[:20]
|
|
228
298
|
# Beside the discovery dir, unless that parent is protected (the Uniflow
|
|
229
299
|
# config): then staging/backups stay in the private state root instead.
|
|
@@ -234,7 +304,7 @@ class Store:
|
|
|
234
304
|
"""Backups of a transaction, wherever the version that made it put them: older
|
|
235
305
|
transactions kept them beside the target even where that is now protected."""
|
|
236
306
|
primary = self.work(target, txid)
|
|
237
|
-
target =
|
|
307
|
+
target = resolved(target)
|
|
238
308
|
key = hashlib.sha256(str(target).encode()).hexdigest()[:20]
|
|
239
309
|
for candidate in (primary, target.parent / ".crow-skills" / key / txid, self.root / "work" / key / txid):
|
|
240
310
|
if os.path.lexists(candidate):
|
|
@@ -397,7 +467,7 @@ class Store:
|
|
|
397
467
|
if item["new"] is not None:
|
|
398
468
|
os.replace(work / "new" / item["name"], dest)
|
|
399
469
|
for target, state in data["after"].items():
|
|
400
|
-
atomic_json(self.state_path(target), state)
|
|
470
|
+
atomic_json(self.state_path(target), retargeted(state, target))
|
|
401
471
|
data["phase"] = "committed"
|
|
402
472
|
atomic_json(journal, data)
|
|
403
473
|
except (Exception, KeyboardInterrupt):
|
|
@@ -423,10 +493,14 @@ class Store:
|
|
|
423
493
|
operations = []
|
|
424
494
|
for target, before in data["before"].items():
|
|
425
495
|
current = self.state(target)
|
|
426
|
-
|
|
496
|
+
# O campo "target" dentro do manifesto guarda o caminho de QUANDO a transação rodou; se
|
|
497
|
+
# o diretório virou dotfiles depois, ele difere do caminho resolvido de hoje e as duas
|
|
498
|
+
# estruturas nunca batem, recusando um rollback perfeitamente válido. A comparação é
|
|
499
|
+
# sobre o CONTEÚDO (as skills instaladas), não sobre a string do caminho.
|
|
500
|
+
if not same_manifest(current, before) and not same_manifest(current, data["after"][target]):
|
|
427
501
|
raise CrowError("recovery_conflict", "Manifesto mudou após a transação; recuperação recusada.")
|
|
428
502
|
for item in reversed(data["items"]):
|
|
429
|
-
dest =
|
|
503
|
+
dest = resolved(Path(item["target"])) / item["name"]
|
|
430
504
|
old = self.existing_work(item["target"], data["id"]) / "old" / item["name"]
|
|
431
505
|
if item.get("link") is not None:
|
|
432
506
|
if same_link(old, item["link"]):
|
|
@@ -462,7 +536,7 @@ class Store:
|
|
|
462
536
|
if backup is not None:
|
|
463
537
|
os.replace(backup, dest)
|
|
464
538
|
for target, state in data["before"].items():
|
|
465
|
-
atomic_json(self.state_path(target), state)
|
|
539
|
+
atomic_json(self.state_path(target), retargeted(state, target))
|
|
466
540
|
data["phase"] = "rolled_back"
|
|
467
541
|
atomic_json(self.root / "transactions" / (data["id"] + ".json"), data)
|
|
468
542
|
|
|
@@ -479,7 +553,10 @@ class Store:
|
|
|
479
553
|
return rollback(self, data, self.home, recovery)
|
|
480
554
|
if not recovery:
|
|
481
555
|
for target, state in data["after"].items():
|
|
482
|
-
|
|
556
|
+
# same_manifest, e não ==: o campo "target" do manifesto guarda o caminho de
|
|
557
|
+
# quando a transação rodou, e um diretório que virou dotfiles depois faria toda
|
|
558
|
+
# comparação falhar, recusando um rollback válido por diferença de string.
|
|
559
|
+
if not same_manifest(self.state(target), state):
|
|
483
560
|
raise CrowError("rollback_conflict", "Há mudanças posteriores; rollback antigo recusado.")
|
|
484
561
|
self._restore(data)
|
|
485
562
|
|
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
|
|
@@ -232,7 +244,7 @@ O Codex lê duas raízes de skills do usuário: `$CODEX_HOME/skills` (padrão `~
|
|
|
232
244
|
- 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
245
|
- 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
246
|
- 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,
|
|
247
|
+
- **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
248
|
- **`$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
249
|
- **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
250
|
- **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": "6a988b314e5b6bb3f2db799d74aec9717c6c3dfa59b4b1ebdeb1e3697fad498b"
|
|
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": "1c2f61e6729c88f0ddc218fba0c5159869e5226d4f7bc0fd2181dd688d4272fe"
|
|
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": "3d25bd5fa2bdbe7e6ec78ce9c44a3b96f13d48b2ceea076c792ffbecd5bbbf1f"
|
|
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": "ab75772bc70758b3c3b16339dcf0ec572af358dd0378f7fc3844dbf039590563"
|
|
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": "7cbc25b39ada200e516079b81a77770e641b267cd5e5868686eb0d87a6604ea8"
|
|
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": "0f425d8d694e1103cd9e56b8b6f6332756bcb8415ca04ec55e0db2f201481807"
|
|
127
127
|
},
|
|
128
128
|
"package.json": {
|
|
129
129
|
"executable": false,
|
|
130
130
|
"kind": "file",
|
|
131
|
-
"sha256": "
|
|
131
|
+
"sha256": "64613f54bedd2d62023be224e30de2dd87bbc0ac7664ed0c49ee8e20a6454ade"
|
|
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": "23cca75e8117dd6110db7e2565b0ce0b740b8efb",
|
|
720
720
|
"schema_version": 1,
|
|
721
|
-
"version": "0.
|
|
721
|
+
"version": "0.16.0"
|
|
722
722
|
}
|