@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.
@@ -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.0"
@@ -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,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 = 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
+ 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():
@@ -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,93 @@ 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
+ 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:
@@ -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
@@ -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(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
+
159
219
  def state(self, target):
160
- target = checked(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
- if str(checked(target)) != target:
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 = checked(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 = checked(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
- if current != before and current != data["after"][target]:
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 = checked(Path(item["target"])) / item["name"]
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
- if self.state(target) != state:
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, 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/crow-skills",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
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": "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": "0f8a284d5ce6dfc8c55a81be861c56c1884156937ecbce3a8800aef4ade76496"
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": "33ba77b7fad64b8cb068d7cdac697505fbe061d60522ef0fabb9b121255cee2d"
41
+ "sha256": "3d25bd5fa2bdbe7e6ec78ce9c44a3b96f13d48b2ceea076c792ffbecd5bbbf1f"
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": "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": "92b7b76ed75e84901dc2a67df3bb6998558200041bc42c8597e708829541a5b1"
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": "9dd1b9cc567e01356ead3d667b3ea559534d44930dffaa9dff7808e3476f25a3"
126
+ "sha256": "0f425d8d694e1103cd9e56b8b6f6332756bcb8415ca04ec55e0db2f201481807"
127
127
  },
128
128
  "package.json": {
129
129
  "executable": false,
130
130
  "kind": "file",
131
- "sha256": "f89633c7d12d50ab189edd74ed7c347d299001db6b9a0397f7a98693abaef936"
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": "4297812328b55870f4b4e7a9bb3e4e7d4f82e358",
719
+ "revision": "23cca75e8117dd6110db7e2565b0ce0b740b8efb",
720
720
  "schema_version": 1,
721
- "version": "0.15.0"
721
+ "version": "0.16.0"
722
722
  }