@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.
@@ -1,3 +1,3 @@
1
1
  """Portable, local-first installer for the crow-skills catalogue."""
2
2
 
3
- __version__ = "0.15.0"
3
+ __version__ = "0.16.1"
@@ -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(os.path.abspath(Path(home) / LEGACY_SHARED))
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": str(Path(custom).expanduser().absolute()), "agents": ["custom"]}]
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 = str(path.absolute())
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():
@@ -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 (``/dev/ttys012``,
553
- ``/dev/pts/3``) or None, and whether ``ps`` actually answered (False on error, timeout or a
554
- non-POSIX system, so the caller may retry later). One ``ps -o tty= -p PID`` limited to
555
- TTY_TIMEOUT; never raises."""
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, capture_output=True,
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
- name = (out or "").strip().splitlines()[0].strip() if (out or "").strip() else ""
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
- def liveness(snap, age, cfg, now=None):
677
- """'live' or None: ended sessions and stale ones (no heartbeat, CLI pid not alive) drop out."""
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:
@@ -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
- return destinations(agents, home, project, custom)
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
- view = broadcast.overview(home, me_key, cwd=cwd)
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
@@ -119,8 +119,16 @@ def shielded(path, home):
119
119
 
120
120
 
121
121
  def protect(path, home):
122
- """Refuse symlinked components, shared prompts and anything inside their targets."""
123
- path = checked(path)
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
- if path.parent == uniflow_store(home) and NAME.fullmatch(path.name):
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)
@@ -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(checked(target)).encode()).hexdigest()
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 = checked(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
- if str(checked(target)) != target:
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 = checked(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 = checked(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
- store_target = "uniflow" in target_info["agents"] and target == uniflow_store(self.home)
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
- if current != before and current != data["after"][target]:
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 = checked(Path(item["target"])) / item["name"]
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
- if self.state(target) != state:
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
- store = uniflow_store(self.home)
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, duas situações que antes não afetavam o codex passam a afetá-lo, exatamente como já valia para claude, gemini e devin: (a) se `~/.codex` (ou o `$CODEX_HOME`) for um symlink, o comando é recusado com `unsafe_destination` — o crow-skills não escreve através de symlink, e a saída pede um caminho físico ou `--dest`; (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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Versioned, complementary agent skills with a protected multi-agent installer",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
package/release.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "crow_skills/__init__.py": {
19
19
  "executable": false,
20
20
  "kind": "file",
21
- "sha256": "26eaa6dfbdfbc6e83983270615e53556227560f236d3064f8fb596bbfceebf0c"
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": "0f8a284d5ce6dfc8c55a81be861c56c1884156937ecbce3a8800aef4ade76496"
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": "33ba77b7fad64b8cb068d7cdac697505fbe061d60522ef0fabb9b121255cee2d"
41
+ "sha256": "d886806e138953dfe47bd19c8f12b4267f5137e510346081c2e3fb15e4b7a8fb"
42
42
  },
43
43
  "crow_skills/cli.py": {
44
44
  "executable": false,
45
45
  "kind": "file",
46
- "sha256": "5a1146f2d6c14554384bc13f0e2f83b3e5ce4b2743dd8a3fee70db3c2f5db9b1"
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": "aa8c16b293a72d6aabd99246a3666e40beaa81d5c1b77e6389341eca463e6a7f"
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": "92b7b76ed75e84901dc2a67df3bb6998558200041bc42c8597e708829541a5b1"
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": "9dd1b9cc567e01356ead3d667b3ea559534d44930dffaa9dff7808e3476f25a3"
126
+ "sha256": "84bc06ac93083a18d81d202409dfe26202feb20d4d6cab7a46520badd2b13a13"
127
127
  },
128
128
  "package.json": {
129
129
  "executable": false,
130
130
  "kind": "file",
131
- "sha256": "f89633c7d12d50ab189edd74ed7c347d299001db6b9a0397f7a98693abaef936"
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": "4297812328b55870f4b4e7a9bb3e4e7d4f82e358",
719
+ "revision": "a0d9e9a088c074f4422b2181ac0629631dbea321",
720
720
  "schema_version": 1,
721
- "version": "0.15.0"
721
+ "version": "0.16.1"
722
722
  }