@andespindola/crow-skills 0.13.1 → 0.15.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,7 @@ 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
22
  from .source import CrowError, Source, valid_name
23
23
  from .storage import Store, checked
24
24
 
@@ -166,6 +166,12 @@ def parser():
166
166
  help="Não aplicar/remover as integrações de interface (status line, tmux) em --global")
167
167
  if name != "uninstall":
168
168
  child.add_argument("--no-trust", action="store_true", help=TRUST_HELP)
169
+ if name == "uninstall":
170
+ # Interno: a migração da pasta compartilhada roda como um uninstall logo depois de um
171
+ # install, e precisa ficar atrelada a ele para que um rollback do install desfaça os dois.
172
+ child.add_argument("--linked-to", dest="linked_to", metavar="TXID", default=argparse.SUPPRESS,
173
+ help=argparse.SUPPRESS) # SUPPRESS: ausente não cria atributo, então
174
+ # uma invocação existente continua parseando com exatamente o mesmo conjunto de atributos.
169
175
  child = command_parser(sub, "guide", [common])
170
176
  child.add_argument("action", choices=("install", "status", "uninstall"))
171
177
  child.add_argument("--replace", action="store_true", help="Autorizar backup e substituição de conteúdo não gerenciado no caminho do guia")
@@ -367,9 +373,9 @@ def guide_sync(args, home, store, command, targets, plan, source):
367
373
  return report, gplan
368
374
 
369
375
 
370
- def apply_chain(store, plan, gplan, home):
376
+ def apply_chain(store, plan, gplan, home, linked=None):
371
377
  """Skills first, then the guide; a failed guide rolls the skills back."""
372
- txid = store.apply(plan)
378
+ txid = store.apply(plan, linked=linked)
373
379
  if gplan is None:
374
380
  return txid, None
375
381
  try:
@@ -716,24 +722,31 @@ def resync_integrations(home, store):
716
722
  pass # Reported by status/doctor; never turns a completed rollback into a failure.
717
723
 
718
724
 
725
+ def linked_transactions(store, txid):
726
+ """Committed transactions that COMPLETE the given one and must be undone before it: the guide,
727
+ and the uninstall that migrated the legacy shared folder after an install."""
728
+ return [t["id"] for t in store.transactions()
729
+ if t.get("linked") == txid and t["phase"] == "committed"]
730
+
731
+
719
732
  def linked_guides(store, txid):
720
733
  """Committed guide transactions that completed the given skills transaction."""
721
734
  return [t["id"] for t in store.transactions() if t.get("kind") == "guide" and t.get("linked") == txid and t["phase"] == "committed"]
722
735
 
723
736
 
724
737
  # 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).
738
+ # Every CLI, codex included since 0.15.0, has its skills installed in its OWN dir, the same one
739
+ # Uniflow links into, so a native copy of a name always just wins there (no second directory).
726
740
  NATIVE_EFFECT = {
727
741
  "claude": "skipped", "gemini": "skipped", "devin": "skipped",
728
- "codex": "both",
742
+ "codex": "skipped",
729
743
  }
730
744
 
731
745
 
732
746
  def uniflow_notice(home, store, targets, plan):
733
747
  """Installing into the Uniflow store while native crow-* copies remain. Uniflow does
734
748
  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."""
749
+ revision) wins there. Report it and the explicit command; never remove anything."""
737
750
  if not any("uniflow" in t["agents"] for t in targets):
738
751
  return None
739
752
  names = sorted({c["name"] for c in plan["changes"] if "uniflow" in c["agents"]})
@@ -765,8 +778,9 @@ def uniflow_notice(home, store, targets, plan):
765
778
  for names_, agents_ in groups.items()]
766
779
  return {"native_duplicates": list(native.values()), "uninspected": uninspected,
767
780
  "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.",
781
+ "store. Desde a 0.15.0 nenhum destino produz effect=both: todo CLI instala no diretório dele, o mesmo em que o "
782
+ "Uniflow projeta o store, então não há uma segunda raiz em que o mesmo nome apareça de novo. Nada foi removido. "
783
+ "Remover a cópia nativa de crow-behaviour remove também o guia daquele agente.",
770
784
  "suggested_commands": commands, "suggested_command": " && ".join(commands) if commands else None}
771
785
 
772
786
 
@@ -803,7 +817,7 @@ def codex_projection_split(home, store, targets, names):
803
817
  """The user-scope codex target and which of the names Codex already sees via Uniflow (install skips them)."""
804
818
  target = next((t for t in targets if projection.native_target("codex", t, home)), None)
805
819
  if target is None or set(target["agents"]) - {"codex"}:
806
- return None # --agents shared: other hosts read that dir without Uniflow's codex projection
820
+ return None # a dir shared with another agent is not Codex's own projected dir
807
821
  projected = projection.projected("codex", home, store)
808
822
  if not projected:
809
823
  return None
@@ -946,16 +960,30 @@ def run(args):
946
960
  "mode": hook.read_mode(home), "activation_verified": False,
947
961
  "reload_hint": "Peça ao host para recarregar/listar skills; reinicie a sessão se necessário."}
948
962
  if any(projection.native_target("codex", t, home) for t in targets):
949
- rows += projection.status_rows(home, store, rows)
963
+ rows[:] = projection.status_rows(home, store, rows) # enriquece no lugar; += contaria duas vezes
950
964
  dupes = projection.duplicates(home, store)
951
965
  names_ = dupes["remove"] + dupes["modified"] + dupes["shared"]
952
966
  if names_:
953
967
  # 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 "")}]
968
+ result.setdefault("warnings", []).append({"code": "codex_duplicates", "skills": names_,
969
+ "message": "O Codex vê " + str(len(names_)) + " skill(s) duas vezes: cópia antiga na pasta "
970
+ "compartilhada " + str(legacy_shared_dir(home)) + " e a instalação em "
971
+ + str(projection.codex_skills_dir(home)) + ". O crow-skills não usa mais a pasta "
972
+ "compartilhada; remover as cópias de lá encerra a duplicação.",
973
+ "fix": "crow-skills uninstall " + ",".join(names_) + " --dest "
974
+ + str(legacy_shared_dir(home)) + " --yes"
975
+ + (" --overwrite-modified" if dupes["modified"] else "")})
976
+ leftovers = [n for n in projection.legacy_leftovers(home, store) if n not in names_]
977
+ if leftovers:
978
+ # Sem Uniflow nada disso duplica uma projeção, então o aviso acima nunca apareceria
979
+ # e uma instalação feita pela 0.14.0 ficaria invisível aqui, ainda carregada pelo Codex.
980
+ result.setdefault("warnings", []).append(
981
+ {"code": "codex_legacy_shared", "skills": leftovers,
982
+ "message": "O crow-skills ainda tem " + str(len(leftovers)) + " skill(s) instalada(s) na pasta "
983
+ "compartilhada antiga " + str(legacy_shared_dir(home)) + ", que esta versão não usa mais; o "
984
+ "Codex continua carregando de lá.",
985
+ "fix": "crow-skills setup --agents codex --yes (instala no diretório do Codex e migra), ou "
986
+ "crow-skills uninstall " + ",".join(leftovers) + " --dest " + str(legacy_shared_dir(home)) + " --yes"})
959
987
  result["groups"] = group_coverage(rows)
960
988
  try:
961
989
  result["autoupdate"] = autoupdate.status(store, home) if global_view else None
@@ -983,7 +1011,7 @@ def run(args):
983
1011
  # on recover, an interrupted guide also undoes the skills it completed.
984
1012
  follow = []
985
1013
  if args.command == "rollback":
986
- before_ids = [g for txid in ids for g in linked_guides(store, txid)]
1014
+ before_ids = [g for txid in ids for g in linked_transactions(store, txid)]
987
1015
  ids, transactions = before_ids + ids, [store.journal(t) for t in before_ids] + transactions
988
1016
  else:
989
1017
  follow = [t["linked"] for t in transactions if t.get("linked")
@@ -1064,15 +1092,19 @@ def run(args):
1064
1092
  summary["codex_projection"] = {
1065
1093
  "via_uniflow": sorted(codex_view["skip"]), "duplicates": codex_view["duplicates"],
1066
1094
  "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}
1095
+ "; nenhuma cópia é criada para o codex. As cópias que ficaram na pasta compartilhada antiga "
1096
+ + str(legacy_shared_dir(home)) + " duplicam a projeção e não são removidas por install; remova-as com o "
1097
+ "comando sugerido ou rode crow-skills setup --yes.",
1098
+ # --dest, nunca --agents codex: esses nomes, no diretório do Codex, são links do Uniflow, e
1099
+ # o uninstall ali recusaria com unsafe_destination.
1100
+ "suggested_command": ("crow-skills uninstall " + " ".join(codex_view["duplicates"]) + " --dest "
1101
+ + str(legacy_shared_dir(home)) + " --yes") if codex_view["duplicates"] else None}
1071
1102
  if plan["items"] or (gplan and gplan["items"]) or ui.get("changes"):
1072
1103
  authorize(args, interactive, summary)
1073
1104
  txid = None
1074
1105
  if not getattr(args, "dry_run", False):
1075
- txid, guides["transaction"] = apply_chain(store, plan, gplan, home)
1106
+ txid, guides["transaction"] = apply_chain(store, plan, gplan, home,
1107
+ linked=getattr(args, "linked_to", None))
1076
1108
  if not ui.get("disabled"):
1077
1109
  # Best effort after the skills/guide transactions; a refusal is reported, never undoes them.
1078
1110
  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.
@@ -21,8 +21,9 @@ seconds, so most prompts cost one small file read).
21
21
 
22
22
  crow-broadcast (crow_broadcast.py, copied beside this file): every event also
23
23
  keeps the session's presence (git repository, CLI pid, PATHS of files its edit
24
- tools target) in the snapshot; a prompt event appends a short digest of other
25
- live sessions in the same repository, only when there are any; ``--check``
24
+ tools target, and the clones its shell calls reach) in the snapshot; a prompt
25
+ event always appends the crow-broadcast block: the digest of the other live
26
+ sessions that touch this work, or one line saying the check found none; ``--check``
26
27
  (a synchronous PreToolUse entry for edit and shell tools) warns about another
27
28
  live session on the same file or clone without writing anything.
28
29
 
@@ -238,13 +239,20 @@ def broadcast_module():
238
239
  return _BROADCAST or None
239
240
 
240
241
 
241
- def resource_hint(home):
242
- """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."""
243
247
  module = resources_module()
244
248
  if module is None:
245
249
  return None
246
250
  try:
247
- 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)
248
256
  if text and hasattr(module, "orphan_hint"):
249
257
  text += module.orphan_hint(home) # last orphan scan, a file read: no ps call per prompt
250
258
  return text
@@ -419,11 +427,20 @@ def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
419
427
  snap.update(extra)
420
428
  if presence:
421
429
  module, touched, ppid = presence[:3]
430
+ # (…, cli pid resolved from the process table on Stop/SessionEnd, shell directories)
431
+ extra = {}
432
+ if len(presence) > 3 and presence[3]:
433
+ extra["cli_pid"] = presence[3]
434
+ if len(presence) > 4 and presence[4]:
435
+ extra["dirs"] = presence[4]
436
+ # A crow_broadcast.py copy older than this hook has no "dirs" parameter. The signature is
437
+ # inspected instead of catching TypeError: a TypeError raised INSIDE fold would otherwise
438
+ # re-run it over a snapshot it had already half-mutated.
439
+ code = getattr(getattr(module, "fold", None), "__code__", None)
440
+ if "dirs" not in getattr(code, "co_varnames", ()):
441
+ extra.pop("dirs", None)
422
442
  try:
423
- if len(presence) > 3: # (…, cli pid resolved from the process table on Stop/SessionEnd)
424
- module.fold(snap, event, touched, ppid=ppid, cli_pid=presence[3])
425
- else:
426
- module.fold(snap, event, touched, ppid=ppid)
443
+ module.fold(snap, event, touched, ppid=ppid, **extra)
427
444
  except Exception: # noqa: BLE001 - presence is best effort
428
445
  pass
429
446
  write_json(path, snap)
@@ -695,7 +712,7 @@ def check_only(host, raw, payload, home):
695
712
  return broadcast_check(host, raw, home, module, me, touched, tree)
696
713
 
697
714
 
698
- def handle(host, raw, payload, home, env=None, check=False):
715
+ def handle(host, raw, payload, home, env=None, check=False, probe=False):
699
716
  """Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
700
717
  env = os.environ if env is None else env
701
718
  if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
@@ -704,7 +721,7 @@ def handle(host, raw, payload, home, env=None, check=False):
704
721
  return check_only(host, raw, payload, home)
705
722
  event = normalise(host, raw, payload)
706
723
  current = read_mode(home)
707
- if raw in PROMPT_EVENTS:
724
+ if raw in PROMPT_EVENTS and not probe:
708
725
  text = prompt_text(payload)
709
726
  match = COMMAND.match(text) if text and len(text) < 64 else None
710
727
  if match and host in ("codex", "devin", "agy"):
@@ -719,7 +736,7 @@ def handle(host, raw, payload, home, env=None, check=False):
719
736
  # 0.9.0 cost; the snapshot's updatedAt is the heartbeat.
720
737
  wanted = raw in PROMPT_EVENTS or raw == "SessionStart" or \
721
738
  (event["event"] in ("tool_start", "tool_end") and event.get("tool") in BROADCAST_TOOLS)
722
- module, presence, touched, tree, checked = broadcast_module() if wanted else None, None, [], [], []
739
+ module, presence, touched, tree, checked, dirs = broadcast_module() if wanted else None, None, [], [], [], []
723
740
  if module is not None:
724
741
  try:
725
742
  cfg = module.config(home)
@@ -729,7 +746,10 @@ def handle(host, raw, payload, home, env=None, check=False):
729
746
  cwd = event.get("cwd") or process_cwd()
730
747
  touched = module.edit_paths(host, event.get("tool"), tool_input, cwd)
731
748
  checked = list(touched)
732
- tree = module.tree_command(host, event.get("tool"), tool_input, cwd)
749
+ try: # one parse for both: work-tree targets and the clones the call reaches
750
+ tree, dirs = module.shell_scan(host, event.get("tool"), tool_input, cwd)
751
+ except (AttributeError, TypeError): # older crow_broadcast.py copy beside this hook
752
+ tree, dirs = module.tree_command(host, event.get("tool"), tool_input, cwd), []
733
753
  if touched and cfg["block_on_conflict"]:
734
754
  # An edit the conflict check is about to refuse is not published as this
735
755
  # session's: otherwise the session that edited first would be locked out too.
@@ -738,13 +758,22 @@ def handle(host, raw, payload, home, env=None, check=False):
738
758
  mine = dict(mine, key=key, cwd=mine.get("cwd") or cwd)
739
759
  if module.check(home, key, mine, touched, (), cfg=cfg)[1]:
740
760
  touched = []
741
- presence = (module, touched, os.getppid())
761
+ presence = (module, touched, os.getppid(), None, dirs)
742
762
  else:
743
763
  module = None
744
764
  except Exception: # noqa: BLE001 - the broadcast never breaks the event
745
765
  module, presence = None, None
746
- stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
747
- 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:
748
777
  cli = record_processes(home, host, event)
749
778
  if cli and snap is not None:
750
779
  bmod = broadcast_module()
@@ -773,12 +802,12 @@ def handle(host, raw, payload, home, env=None, check=False):
773
802
  stamp = current["mode"] + "@" + str(current["updatedAt"])
774
803
  if host in ("codex", "gemini", "devin") and (raw == "SessionStart" or (seen and seen != stamp)):
775
804
  system = indicator(current["mode"]) # visible line only at start or when the mode changed
776
- if snap is not None and seen != stamp:
805
+ if snap is not None and seen != stamp and not probe:
777
806
  update_session(home, dict(stamped, event="other"), {"modeSeen": stamp, "mode": current["mode"]})
778
807
  if raw == "SessionStart":
779
808
  return output_for(host, raw, system=system) if system else None
780
809
  context = context_line(current["mode"])
781
- memory = resource_hint(home)
810
+ memory = resource_hint(home, write_through=not probe)
782
811
  if memory:
783
812
  context += "\n" + memory
784
813
  if module is not None and snap is not None:
@@ -841,8 +870,8 @@ def fast_hook_args(argv):
841
870
  if name in ("--host", "--event", "--home") and i + 1 < len(argv):
842
871
  values[name[2:]] = argv[i + 1]
843
872
  i += 2
844
- elif name == "--check":
845
- flags.add("check")
873
+ elif name in ("--check", "--probe"):
874
+ flags.add(name[2:])
846
875
  i += 1
847
876
  else:
848
877
  return None
@@ -850,7 +879,7 @@ def fast_hook_args(argv):
850
879
  return None
851
880
  import types
852
881
  return types.SimpleNamespace(command="hook", host=values["host"], event=values["event"], home=values.get("home"),
853
- check="check" in flags)
882
+ check="check" in flags, probe="probe" in flags)
854
883
 
855
884
 
856
885
  def main(argv=None):
@@ -869,6 +898,8 @@ def parse_args(argv):
869
898
  hook.add_argument("--event", required=True)
870
899
  hook.add_argument("--home")
871
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")
872
903
  mode = sub.add_parser("mode")
873
904
  mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
874
905
  mode.add_argument("--home")
@@ -895,11 +926,11 @@ def run_main(args):
895
926
  try:
896
927
  raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
897
928
  payload = json.loads(raw.decode("utf-8")) if raw.strip() and len(raw) <= MAX_STDIN else {}
898
- 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))
899
930
  if result:
900
931
  sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
901
932
  sys.stdout.flush()
902
- if args.event == "SessionStart" and not args.check:
933
+ if args.event == "SessionStart" and not args.check and not getattr(args, "probe", False):
903
934
  prune_sessions(home)
904
935
  module = broadcast_module()
905
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)