@andespindola/crow-skills 0.14.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.
@@ -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
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
 
@@ -166,6 +167,12 @@ def parser():
166
167
  help="Não aplicar/remover as integrações de interface (status line, tmux) em --global")
167
168
  if name != "uninstall":
168
169
  child.add_argument("--no-trust", action="store_true", help=TRUST_HELP)
170
+ if name == "uninstall":
171
+ # Interno: a migração da pasta compartilhada roda como um uninstall logo depois de um
172
+ # install, e precisa ficar atrelada a ele para que um rollback do install desfaça os dois.
173
+ child.add_argument("--linked-to", dest="linked_to", metavar="TXID", default=argparse.SUPPRESS,
174
+ help=argparse.SUPPRESS) # SUPPRESS: ausente não cria atributo, então
175
+ # uma invocação existente continua parseando com exatamente o mesmo conjunto de atributos.
169
176
  child = command_parser(sub, "guide", [common])
170
177
  child.add_argument("action", choices=("install", "status", "uninstall"))
171
178
  child.add_argument("--replace", action="store_true", help="Autorizar backup e substituição de conteúdo não gerenciado no caminho do guia")
@@ -255,7 +262,11 @@ def scope(args, home, interactive=False, read=False):
255
262
  agents = ",".join(AGENTS)
256
263
  elif interactive:
257
264
  agents = prompt("Agentes separados por vírgula (" + ",".join(AGENTS) + "):")
258
- 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
259
270
 
260
271
 
261
272
  def authorize(args, interactive, summary):
@@ -351,6 +362,22 @@ def guide_sync(args, home, store, command, targets, plan, source):
351
362
  report["skipped"].append({"agent": agent, "reason": "crow-behaviour ausente; guia mantido. Confira crow-skills guide status."})
352
363
  elif not record and command != "uninstall":
353
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."})
354
381
  if not actions:
355
382
  return report, None
356
383
  if source is None and "install" in actions.values():
@@ -367,9 +394,9 @@ def guide_sync(args, home, store, command, targets, plan, source):
367
394
  return report, gplan
368
395
 
369
396
 
370
- def apply_chain(store, plan, gplan, home):
397
+ def apply_chain(store, plan, gplan, home, linked=None):
371
398
  """Skills first, then the guide; a failed guide rolls the skills back."""
372
- txid = store.apply(plan)
399
+ txid = store.apply(plan, linked=linked)
373
400
  if gplan is None:
374
401
  return txid, None
375
402
  try:
@@ -567,7 +594,9 @@ def broadcast_view(args, home, owner=None):
567
594
  cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
568
595
  owner = owner if owner is not None else broadcast_owner(args, home, cwd)
569
596
  me_key = owner["key"] if owner else None
570
- 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())
571
600
  if view["me"] is not None and view["me"].get("cwd") is None:
572
601
  view["me"]["cwd"] = cwd
573
602
  view["owner"] = owner
@@ -716,24 +745,31 @@ def resync_integrations(home, store):
716
745
  pass # Reported by status/doctor; never turns a completed rollback into a failure.
717
746
 
718
747
 
748
+ def linked_transactions(store, txid):
749
+ """Committed transactions that COMPLETE the given one and must be undone before it: the guide,
750
+ and the uninstall that migrated the legacy shared folder after an install."""
751
+ return [t["id"] for t in store.transactions()
752
+ if t.get("linked") == txid and t["phase"] == "committed"]
753
+
754
+
719
755
  def linked_guides(store, txid):
720
756
  """Committed guide transactions that completed the given skills transaction."""
721
757
  return [t["id"] for t in store.transactions() if t.get("kind") == "guide" and t.get("linked") == txid and t["phase"] == "committed"]
722
758
 
723
759
 
724
760
  # What a native copy means once Uniflow links its store into each CLI's user skills dir.
725
- # Uniflow links codex into ~/.codex/skills, not into ~/.agents/skills (this CLI's codex root).
761
+ # Every CLI, codex included since 0.15.0, has its skills installed in its OWN dir, the same one
762
+ # Uniflow links into, so a native copy of a name always just wins there (no second directory).
726
763
  NATIVE_EFFECT = {
727
764
  "claude": "skipped", "gemini": "skipped", "devin": "skipped",
728
- "codex": "both",
765
+ "codex": "skipped",
729
766
  }
730
767
 
731
768
 
732
769
  def uniflow_notice(home, store, targets, plan):
733
770
  """Installing into the Uniflow store while native crow-* copies remain. Uniflow does
734
771
  not link a name a CLI already has in the same dir, so that copy (possibly another
735
- revision) wins there; codex reads ~/.agents/skills and Uniflow's ~/.codex/skills
736
- link, so it sees both. Report it and the explicit command; never remove anything."""
772
+ revision) wins there. Report it and the explicit command; never remove anything."""
737
773
  if not any("uniflow" in t["agents"] for t in targets):
738
774
  return None
739
775
  names = sorted({c["name"] for c in plan["changes"] if "uniflow" in c["agents"]})
@@ -765,8 +801,9 @@ def uniflow_notice(home, store, targets, plan):
765
801
  for names_, agents_ in groups.items()]
766
802
  return {"native_duplicates": list(native.values()), "uninspected": uninspected,
767
803
  "note": "effect=skipped: o Uniflow não cria o link de um nome que o agente já tem nativo, e essa cópia vale no lugar do "
768
- "store. effect=both: o Uniflow liga o store em ~/.codex/skills e o Codex também lê ~/.agents/skills, então vê as "
769
- "duas cópias. Nada foi removido. Remover a cópia nativa de crow-behaviour remove também o guia daquele agente.",
804
+ "store. Desde a 0.15.0 nenhum destino produz effect=both: todo CLI instala no diretório dele, o mesmo em que o "
805
+ "Uniflow projeta o store, então não há uma segunda raiz em que o mesmo nome apareça de novo. Nada foi removido. "
806
+ "Remover a cópia nativa de crow-behaviour remove também o guia daquele agente.",
770
807
  "suggested_commands": commands, "suggested_command": " && ".join(commands) if commands else None}
771
808
 
772
809
 
@@ -803,7 +840,7 @@ def codex_projection_split(home, store, targets, names):
803
840
  """The user-scope codex target and which of the names Codex already sees via Uniflow (install skips them)."""
804
841
  target = next((t for t in targets if projection.native_target("codex", t, home)), None)
805
842
  if target is None or set(target["agents"]) - {"codex"}:
806
- return None # --agents shared: other hosts read that dir without Uniflow's codex projection
843
+ return None # a dir shared with another agent is not Codex's own projected dir
807
844
  projected = projection.projected("codex", home, store)
808
845
  if not projected:
809
846
  return None
@@ -946,16 +983,30 @@ def run(args):
946
983
  "mode": hook.read_mode(home), "activation_verified": False,
947
984
  "reload_hint": "Peça ao host para recarregar/listar skills; reinicie a sessão se necessário."}
948
985
  if any(projection.native_target("codex", t, home) for t in targets):
949
- rows += projection.status_rows(home, store, rows)
986
+ rows[:] = projection.status_rows(home, store, rows) # enriquece no lugar; += contaria duas vezes
950
987
  dupes = projection.duplicates(home, store)
951
988
  names_ = dupes["remove"] + dupes["modified"] + dupes["shared"]
952
989
  if names_:
953
990
  # A warning, not unhealthy: both copies are intact and work; Codex just lists them twice.
954
- result["warnings"] = [{"code": "codex_duplicates", "skills": names_,
955
- "message": "O Codex vê " + str(len(names_)) + " skill(s) duas vezes (cópia em ~/.agents/skills "
956
- "e link do Uniflow em " + str(projection.codex_skills_dir(home)) + ").",
957
- "fix": "crow-skills setup --agents codex --yes"
958
- + (" --overwrite-modified" if dupes["modified"] else "")}]
991
+ result.setdefault("warnings", []).append({"code": "codex_duplicates", "skills": names_,
992
+ "message": "O Codex vê " + str(len(names_)) + " skill(s) duas vezes: cópia antiga na pasta "
993
+ "compartilhada " + str(legacy_shared_dir(home)) + " e a instalação em "
994
+ + str(projection.codex_skills_dir(home)) + ". O crow-skills não usa mais a pasta "
995
+ "compartilhada; remover as cópias de lá encerra a duplicação.",
996
+ "fix": "crow-skills uninstall " + ",".join(names_) + " --dest "
997
+ + str(legacy_shared_dir(home)) + " --yes"
998
+ + (" --overwrite-modified" if dupes["modified"] else "")})
999
+ leftovers = [n for n in projection.legacy_leftovers(home, store) if n not in names_]
1000
+ if leftovers:
1001
+ # Sem Uniflow nada disso duplica uma projeção, então o aviso acima nunca apareceria
1002
+ # e uma instalação feita pela 0.14.0 ficaria invisível aqui, ainda carregada pelo Codex.
1003
+ result.setdefault("warnings", []).append(
1004
+ {"code": "codex_legacy_shared", "skills": leftovers,
1005
+ "message": "O crow-skills ainda tem " + str(len(leftovers)) + " skill(s) instalada(s) na pasta "
1006
+ "compartilhada antiga " + str(legacy_shared_dir(home)) + ", que esta versão não usa mais; o "
1007
+ "Codex continua carregando de lá.",
1008
+ "fix": "crow-skills setup --agents codex --yes (instala no diretório do Codex e migra), ou "
1009
+ "crow-skills uninstall " + ",".join(leftovers) + " --dest " + str(legacy_shared_dir(home)) + " --yes"})
959
1010
  result["groups"] = group_coverage(rows)
960
1011
  try:
961
1012
  result["autoupdate"] = autoupdate.status(store, home) if global_view else None
@@ -983,7 +1034,7 @@ def run(args):
983
1034
  # on recover, an interrupted guide also undoes the skills it completed.
984
1035
  follow = []
985
1036
  if args.command == "rollback":
986
- before_ids = [g for txid in ids for g in linked_guides(store, txid)]
1037
+ before_ids = [g for txid in ids for g in linked_transactions(store, txid)]
987
1038
  ids, transactions = before_ids + ids, [store.journal(t) for t in before_ids] + transactions
988
1039
  else:
989
1040
  follow = [t["linked"] for t in transactions if t.get("linked")
@@ -1064,15 +1115,19 @@ def run(args):
1064
1115
  summary["codex_projection"] = {
1065
1116
  "via_uniflow": sorted(codex_view["skip"]), "duplicates": codex_view["duplicates"],
1066
1117
  "note": "O Codex já vê estas skills pelo link do Uniflow em " + str(projection.codex_skills_dir(home)) +
1067
- "; nenhuma cópia nativa é criada para o codex. Cópias nativas que duplicam a projeção não são removidas "
1068
- "por install; remova-as com o comando sugerido ou rode crow-skills setup --yes.",
1069
- "suggested_command": ("crow-skills uninstall " + " ".join(codex_view["duplicates"]) + " --agents codex --global")
1070
- if codex_view["duplicates"] else None}
1118
+ "; nenhuma cópia é criada para o codex. As cópias que ficaram na pasta compartilhada antiga "
1119
+ + str(legacy_shared_dir(home)) + " duplicam a projeção e não são removidas por install; remova-as com o "
1120
+ "comando sugerido ou rode crow-skills setup --yes.",
1121
+ # --dest, nunca --agents codex: esses nomes, no diretório do Codex, são links do Uniflow, e
1122
+ # o uninstall ali recusaria com unsafe_destination.
1123
+ "suggested_command": ("crow-skills uninstall " + " ".join(codex_view["duplicates"]) + " --dest "
1124
+ + str(legacy_shared_dir(home)) + " --yes") if codex_view["duplicates"] else None}
1071
1125
  if plan["items"] or (gplan and gplan["items"]) or ui.get("changes"):
1072
1126
  authorize(args, interactive, summary)
1073
1127
  txid = None
1074
1128
  if not getattr(args, "dry_run", False):
1075
- txid, guides["transaction"] = apply_chain(store, plan, gplan, home)
1129
+ txid, guides["transaction"] = apply_chain(store, plan, gplan, home,
1130
+ linked=getattr(args, "linked_to", None))
1076
1131
  if not ui.get("disabled"):
1077
1132
  # Best effort after the skills/guide transactions; a refusal is reported, never undoes them.
1078
1133
  try:
@@ -52,7 +52,6 @@ MECHANISMS = {
52
52
  "load": "use a ferramenta de skills do Devin ou digite /devin:nome"},
53
53
  }
54
54
  UNVERIFIED = {
55
- "shared": "shared é um diretório de skills, não um CLI; não há onde gravar instruções.",
56
55
  "uniflow": "o Uniflow infere o uso de crow-behaviour por padrão e já projeta o store nos agentes; não há guia a gravar.",
57
56
  }
58
57
  # Shared prompt files (often symlinks into ~/.config/uniflow) and their targets.
@@ -120,8 +119,16 @@ def shielded(path, home):
120
119
 
121
120
 
122
121
  def protect(path, home):
123
- """Refuse symlinked components, shared prompts and anything inside their targets."""
124
- 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))
125
132
  if shielded(path, home):
126
133
  raise CrowError("protected_path", "Arquivo de prompt compartilhado (ou alvo dele) não é gravado: " + str(path))
127
134
  return path
@@ -239,13 +239,20 @@ def broadcast_module():
239
239
  return _BROADCAST or None
240
240
 
241
241
 
242
- def resource_hint(home):
243
- """The one-line memory hint, or None (no pressure, no probe, or any failure)."""
242
+ def resource_hint(home, write_through=True):
243
+ """The one-line memory hint, or None (no pressure, no probe, or any failure).
244
+
245
+ write_through=False for `--probe`: the reading is still taken, but a cache miss must not write
246
+ resources-cache.json, or "exercise the hook without writing anything" would be false."""
244
247
  module = resources_module()
245
248
  if module is None:
246
249
  return None
247
250
  try:
248
- text = module.hint(module.probe(home, processes=False, use_cache=True))
251
+ try:
252
+ reading = module.probe(home, processes=False, use_cache=True, write_through=write_through)
253
+ except TypeError: # crow_resources.py copy older than this hook
254
+ reading = module.probe(home, processes=False, use_cache=True)
255
+ text = module.hint(reading)
249
256
  if text and hasattr(module, "orphan_hint"):
250
257
  text += module.orphan_hint(home) # last orphan scan, a file read: no ps call per prompt
251
258
  return text
@@ -705,7 +712,7 @@ def check_only(host, raw, payload, home):
705
712
  return broadcast_check(host, raw, home, module, me, touched, tree)
706
713
 
707
714
 
708
- def handle(host, raw, payload, home, env=None, check=False):
715
+ def handle(host, raw, payload, home, env=None, check=False, probe=False):
709
716
  """Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
710
717
  env = os.environ if env is None else env
711
718
  if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
@@ -714,7 +721,7 @@ def handle(host, raw, payload, home, env=None, check=False):
714
721
  return check_only(host, raw, payload, home)
715
722
  event = normalise(host, raw, payload)
716
723
  current = read_mode(home)
717
- if raw in PROMPT_EVENTS:
724
+ if raw in PROMPT_EVENTS and not probe:
718
725
  text = prompt_text(payload)
719
726
  match = COMMAND.match(text) if text and len(text) < 64 else None
720
727
  if match and host in ("codex", "devin", "agy"):
@@ -756,8 +763,17 @@ def handle(host, raw, payload, home, env=None, check=False):
756
763
  module = None
757
764
  except Exception: # noqa: BLE001 - the broadcast never breaks the event
758
765
  module, presence = None, None
759
- stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
760
- if raw in STOP_EVENTS:
766
+ if probe:
767
+ # Everything a real event would compute, nothing it would write: no event appended, no session
768
+ # snapshot, no presence. Validating that a CLI's hook is wired must never invent a live session.
769
+ stamped = dict(event, mode=current["mode"])
770
+ snap = {"key": session_key(host, event.get("session"), event.get("cwd")), "host": host,
771
+ "session": event.get("session"), "cwd": event.get("cwd") or process_cwd(),
772
+ "state": "working", "presence": {}}
773
+ else:
774
+ stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(),
775
+ presence=presence)
776
+ if raw in STOP_EVENTS and not probe:
761
777
  cli = record_processes(home, host, event)
762
778
  if cli and snap is not None:
763
779
  bmod = broadcast_module()
@@ -786,12 +802,12 @@ def handle(host, raw, payload, home, env=None, check=False):
786
802
  stamp = current["mode"] + "@" + str(current["updatedAt"])
787
803
  if host in ("codex", "gemini", "devin") and (raw == "SessionStart" or (seen and seen != stamp)):
788
804
  system = indicator(current["mode"]) # visible line only at start or when the mode changed
789
- if snap is not None and seen != stamp:
805
+ if snap is not None and seen != stamp and not probe:
790
806
  update_session(home, dict(stamped, event="other"), {"modeSeen": stamp, "mode": current["mode"]})
791
807
  if raw == "SessionStart":
792
808
  return output_for(host, raw, system=system) if system else None
793
809
  context = context_line(current["mode"])
794
- memory = resource_hint(home)
810
+ memory = resource_hint(home, write_through=not probe)
795
811
  if memory:
796
812
  context += "\n" + memory
797
813
  if module is not None and snap is not None:
@@ -854,8 +870,8 @@ def fast_hook_args(argv):
854
870
  if name in ("--host", "--event", "--home") and i + 1 < len(argv):
855
871
  values[name[2:]] = argv[i + 1]
856
872
  i += 2
857
- elif name == "--check":
858
- flags.add("check")
873
+ elif name in ("--check", "--probe"):
874
+ flags.add(name[2:])
859
875
  i += 1
860
876
  else:
861
877
  return None
@@ -863,7 +879,7 @@ def fast_hook_args(argv):
863
879
  return None
864
880
  import types
865
881
  return types.SimpleNamespace(command="hook", host=values["host"], event=values["event"], home=values.get("home"),
866
- check="check" in flags)
882
+ check="check" in flags, probe="probe" in flags)
867
883
 
868
884
 
869
885
  def main(argv=None):
@@ -882,6 +898,8 @@ def parse_args(argv):
882
898
  hook.add_argument("--event", required=True)
883
899
  hook.add_argument("--home")
884
900
  hook.add_argument("--check", action="store_true")
901
+ # Exercise the hook without side effects: validating a CLI's wiring must not register a session.
902
+ hook.add_argument("--probe", action="store_true")
885
903
  mode = sub.add_parser("mode")
886
904
  mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
887
905
  mode.add_argument("--home")
@@ -908,11 +926,11 @@ def run_main(args):
908
926
  try:
909
927
  raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
910
928
  payload = json.loads(raw.decode("utf-8")) if raw.strip() and len(raw) <= MAX_STDIN else {}
911
- result = handle(args.host, args.event, payload, home, check=args.check)
929
+ result = handle(args.host, args.event, payload, home, check=args.check, probe=getattr(args, "probe", False))
912
930
  if result:
913
931
  sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
914
932
  sys.stdout.flush()
915
- if args.event == "SessionStart" and not args.check:
933
+ if args.event == "SessionStart" and not args.check and not getattr(args, "probe", False):
916
934
  prune_sessions(home)
917
935
  module = broadcast_module()
918
936
  if module is not None:
@@ -30,7 +30,7 @@ from . import hook
30
30
  from . import integrations
31
31
  from . import resources
32
32
  from . import ui
33
- from .agents import destinations, uniflow_config_dir
33
+ from .agents import destinations, legacy_shared_dir, uniflow_config_dir
34
34
  from .source import CrowError, Source
35
35
  from .storage import Store
36
36
 
@@ -251,6 +251,9 @@ def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
251
251
  if not install:
252
252
  entry.update(refresh_extras(agent, home, dry_run, not getattr(args, "no_trust", False), getattr(args, "source", None),
253
253
  getattr(args, "ref", None), replace, overwrite))
254
+ if agent == "codex":
255
+ # Nada a instalar: o que vale como substituto é o que já está lá (unchanged).
256
+ migrate_legacy(args, home, store, entry, dry_run, planned=entry["unchanged"])
254
257
  return entry
255
258
  argv = ["install", *install, "--agents", agent, "--global", "--json", "--home", str(home),
256
259
  "--dry-run" if dry_run else "--yes"]
@@ -275,22 +278,139 @@ def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
275
278
  entry["transaction"] = data.get("transaction")
276
279
  if data.get("uniflow"):
277
280
  entry["uniflow"] = data["uniflow"]
281
+ if agent == "codex":
282
+ # Depois da instalação no diretório do Codex, nunca antes: a cópia legada só sai quando
283
+ # existe uma instalação no lugar novo. Num dry run o disco não foi tocado, então o que este
284
+ # run instalaria conta como substituto — senão o preview mentiria sobre o apply.
285
+ migrate_legacy(args, home, store, entry, dry_run,
286
+ planned=(entry["changed"] + entry["unchanged"]) if dry_run else ())
278
287
  return entry
279
288
 
280
289
 
281
290
  DUPLICATE = "removida: duplicava a projeção do Uniflow"
282
291
 
283
292
 
293
+ def migrate_legacy(args, home, store, entry, dry_run, planned=()):
294
+ """legacy_shared_step, but a failure there never costs the agent its result.
295
+
296
+ planned: names this run installs (or already has) in the CLI's own dir. In a dry run nothing is
297
+ written there, so without this the preview would judge every name as "has no replacement" and
298
+ predict the opposite of what the apply does."""
299
+ try:
300
+ legacy_shared_step(args, home, store, entry, dry_run, planned=planned)
301
+ except (CrowError, OSError, ValueError, KeyError, TypeError) as exc:
302
+ entry["legacy_shared"] = {"state": "warn", "note": "migração da pasta compartilhada antiga não concluída: " + str(exc),
303
+ "fix": "crow-skills doctor"}
304
+
305
+
306
+ def legacy_shared_step(args, home, store, entry, dry_run, planned=()):
307
+ """Remove what crow-skills left in the legacy shared folder (~/.agents/skills), where codex
308
+ installed until 0.14.0, now that codex installs in its own dir.
309
+
310
+ duplicates() only sees names Uniflow projects, so a copy of a NON-projected skill (the
311
+ coordinator itself, typically) would otherwise stay there for ever and keep being listed by
312
+ Codex beside the real install. Only crow-skills-managed, intact copies go, through the normal
313
+ uninstall (backup, transaction, rollback); an edited copy, or one also recorded for another
314
+ agent of that dir, is kept and reported. Nothing that crow-skills did not install is touched.
315
+
316
+ A copy is removed ONLY when that name really works in Codex's own dir — an intact install of
317
+ ours, or a healthy Uniflow link. Otherwise the legacy copy is the only working skill Codex has
318
+ (a broken store entry leaves the link occupying the name in the new dir), and removing it would
319
+ take the skill away instead of migrating it.
320
+ """
321
+ legacy = legacy_shared_dir(home)
322
+ # Mesmo diretório (inclusive via symlink): não há nada para migrar e avisar seria ruído.
323
+ if os.path.realpath(legacy) == os.path.realpath(entry["target"]):
324
+ return
325
+ if legacy.is_symlink():
326
+ # Não seguimos symlink para apagar nada, mas quem tem a pasta nos dotfiles precisa saber
327
+ # que a migração não aconteceu, em vez de supor que aconteceu.
328
+ entry["legacy_shared"] = {"state": "warn", "action": "skip", "path": str(legacy),
329
+ "note": "a pasta compartilhada antiga é um symlink; nada foi tocado e a migração não "
330
+ "aconteceu. Mova o conteúdo você mesmo ou aponte --dest para o caminho físico."}
331
+ return
332
+ if not legacy.is_dir():
333
+ return
334
+ target = {"path": str(legacy), "agents": ["codex"]}
335
+ try:
336
+ records = store.state(target["path"])["installed"]
337
+ rows = {r["name"]: r for r in store.status([target], sorted(records)) if r["name"]}
338
+ except (CrowError, OSError) as exc:
339
+ entry["legacy_shared"] = {"state": "warn", "note": str(exc), "path": str(legacy)}
340
+ return
341
+ # What actually works in Codex's own dir right now: our intact install, or a healthy projection.
342
+ from . import projection
343
+ try:
344
+ own = {r["name"]: r["status"] for r in store.status([destinations("codex", home)[0]], sorted(records)) if r["name"]}
345
+ except (CrowError, OSError):
346
+ own = {}
347
+ try:
348
+ healthy = {k for k, kind in projection.links(home, store).items() if kind == "managed"}
349
+ except (CrowError, OSError):
350
+ healthy = set()
351
+ # One reason per name: a more specific one already recorded for this name (a broken projection,
352
+ # an unsafe target) explains it better than anything this step would add.
353
+ already = {k["name"] for k in entry.get("kept") or []}
354
+ remove, kept, deferred = [], [], []
355
+ for name in sorted(records):
356
+ row = rows.get(name)
357
+ if not row or row["status"] not in ("installed", "modified", "missing"):
358
+ continue # unmanaged, absent or unreadable: not ours to remove
359
+ if name in already:
360
+ deferred.append(name) # já explicado por uma razão mais específica deste mesmo nome
361
+ continue
362
+ if own.get(name) != "installed" and name not in healthy and name not in set(planned or ()):
363
+ kept.append({"name": name, "status": "legacy_shared_without_replacement",
364
+ "reason": "a cópia na pasta compartilhada antiga é a única que funciona: no diretório do Codex "
365
+ "este nome não tem instalação íntegra nem projeção saudável do Uniflow",
366
+ "fix": "conserte a instalação no diretório do Codex (por exemplo crow-skills setup --agents codex "
367
+ "--yes, ou corrija a entrada do store do Uniflow) e rode de novo"})
368
+ continue
369
+ others = set(records.get(name, {}).get("agents") or ["codex"]) - {"codex"}
370
+ if others:
371
+ kept.append({"name": name, "status": "legacy_shared_of_another_agent",
372
+ "reason": "também registrada para " + ",".join(sorted(others)) + " naquele diretório",
373
+ "fix": "crow-skills uninstall " + name + " --dest " + str(legacy) + " --yes"})
374
+ elif row["status"] == "installed":
375
+ remove.append(name)
376
+ else:
377
+ kept.append({"name": name, "status": "legacy_shared_modified",
378
+ "reason": "cópia editada na pasta compartilhada antiga",
379
+ "fix": "crow-skills uninstall " + name + " --dest " + str(legacy) + " --overwrite-modified --yes"})
380
+ entry["kept"] += kept
381
+ if not remove:
382
+ # Nunca silencioso: se ainda há cópias nossas lá, o resumo diz que a pasta foi olhada e por
383
+ # que nada saiu — senão não há como auditar uma migração que não aconteceu.
384
+ if kept or deferred:
385
+ entry["legacy_shared"] = {"state": "warn", "action": "keep", "path": str(legacy),
386
+ "kept": [k["name"] for k in kept] + deferred}
387
+ return
388
+ argv = ["uninstall", *remove, "--dest", str(legacy), "--json", "--home", str(home),
389
+ "--dry-run" if dry_run else "--yes", "--no-integrations"]
390
+ if entry.get("transaction"):
391
+ # Atrelada à transação do install: um rollback dele restaura primeiro estas cópias, em vez
392
+ # de tirar a instalação nova e deixar o usuário sem nenhuma.
393
+ argv += ["--linked-to", entry["transaction"]]
394
+ data = _cli().run(_cli().parser().parse_args(argv))
395
+ entry["legacy_shared"] = {"state": "ok", "action": "plan" if dry_run else "remove", "path": str(legacy),
396
+ "removed": [c["name"] for c in data["changes"] if c["action"] != "unchanged"],
397
+ "transaction": data.get("transaction"),
398
+ "note": "o crow-skills não usa mais a pasta compartilhada; as cópias de lá foram removidas "
399
+ "(com backup) agora que o codex instala no diretório dele"}
400
+
401
+
284
402
  def codex_projection_step(args, home, store, entry, names, install, waiting, store_names, dry_run):
285
- """Codex sees ~/.codex/skills (Uniflow's links to its store) and ~/.agents/skills (our copies) and lists a
286
- same-name skill twice. A name Codex sees via a managed projection gets no native copy, and an intact
287
- managed native copy of it is removed through uninstall (backup, transaction, rollback). Returns the
288
- names still to install natively."""
403
+ """Codex sees its own dir (where crow-skills installs and Uniflow links its store) and also the legacy
404
+ shared ~/.agents/skills, so a copy left there by a version before 0.15.0 is still listed twice. A name
405
+ Codex sees via a managed projection gets no copy of its own, and an intact managed legacy copy of it is
406
+ removed through uninstall against that legacy dir (backup, transaction, rollback). Returns the names
407
+ still to install."""
289
408
  from . import projection
290
409
  for name, kind in sorted(projection.links(home, store).items()):
291
410
  if kind == "modified" and name in names:
292
411
  entry["kept"].append({"name": name, "status": "uniflow_link_modified",
293
- "reason": guide.BROKEN_REASON + "; a cópia nativa em ~/.agents/skills continua valendo",
412
+ "reason": guide.BROKEN_REASON + "; o link do Uniflow ocupa este nome no diretório do "
413
+ "Codex, então conserte a entrada do store",
294
414
  "fix": guide.broken_fix(name)})
295
415
  projected = projection.projected("codex", home, store)
296
416
  if not projected:
@@ -310,18 +430,20 @@ def codex_projection_step(args, home, store, entry, names, install, waiting, sto
310
430
  for name in dupes["modified"]:
311
431
  if name in selected and not overwrite:
312
432
  entry["kept"].append({"name": name, "status": "duplicate_modified",
313
- "reason": "cópia nativa editada que duplica a projeção do Uniflow no Codex",
433
+ "reason": "cópia editada na pasta compartilhada antiga, que duplica a projeção do Uniflow no Codex",
314
434
  "fix": "revise e, para remover com backup: crow-skills setup --agents codex --overwrite-modified --yes"})
315
435
  for name in dupes["shared"]:
316
436
  if name in selected:
317
437
  entry["kept"].append({"name": name, "status": "duplicate_shared",
318
- "reason": "cópia nativa também registrada para shared (diretório lido por outros hosts); "
319
- "mantida, e o Codex a vê duas vezes",
438
+ "reason": "cópia na pasta compartilhada antiga também registrada para outro agente que lê "
439
+ "aquele diretório; mantida, e o Codex a vê duas vezes",
320
440
  "fix": "se nenhum outro host precisa dela: crow-skills uninstall " + name +
321
- " --agents codex,shared --global --yes"})
441
+ " --dest " + str(legacy_shared_dir(home)) + " --yes"})
322
442
  if remove:
323
443
  cli = _cli()
324
- argv = ["uninstall", *remove, "--agents", "codex", "--global", "--json", "--home", str(home),
444
+ # The duplicates live in the legacy shared folder, never in Codex's own dir: --dest targets it
445
+ # explicitly, since no agent name points there any more.
446
+ argv = ["uninstall", *remove, "--dest", str(legacy_shared_dir(home)), "--json", "--home", str(home),
325
447
  "--dry-run" if dry_run else "--yes"]
326
448
  if overwrite:
327
449
  argv.append("--overwrite-modified")
@@ -473,6 +595,16 @@ def summarize(detected, entries, store, home, probe, dry_run, trust=True):
473
595
  parts.append(str(len(entry["removed"])) + (" a remover" if dry_run else " removidas") + " (duplicavam o Uniflow)")
474
596
  if kept:
475
597
  parts.append(str(len(kept)) + " mantidas")
598
+ legacy = entry.get("legacy_shared") or {}
599
+ if legacy.get("removed"):
600
+ # Apagar diretórios do usuário nunca é silencioso: aparece na linha do agente e na nota,
601
+ # com o caminho, para quem só lê a saída humana saber o que saiu.
602
+ parts.append(str(len(legacy["removed"])) + (" a tirar" if dry_run else " tiradas") + " da pasta compartilhada antiga")
603
+ item["notes"].append(("a remover de " if dry_run else "removidas de ") + legacy.get("path", "?") + ": " +
604
+ ", ".join(legacy["removed"]))
605
+ elif legacy.get("state") == "warn":
606
+ item["notes"].append("pasta compartilhada antiga " + legacy.get("path", "?") + ": " +
607
+ (legacy.get("note") or ("mantidas " + ", ".join(legacy.get("kept") or []))))
476
608
  item["skills"] = ", ".join(parts) or "—"
477
609
  guides = entry.get("guides") or {}
478
610
  guide_change = next((c for c in guides.get("changes", []) if c.get("agent") == cli["agent"]), None)