@andespindola/crow-skills 0.27.1 → 0.28.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.
@@ -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, legacy_shared_dir, uniflow_config_dir
33
+ from .agents import destinations, legacy_shared_dir
34
34
  from .source import CrowError, Source
35
35
  from .storage import Store
36
36
 
@@ -47,12 +47,10 @@ CLIS = (
47
47
  "agent": "gemini", "integrations": ("agy-hooks", "agy-statusline")},
48
48
  {"id": "devin", "label": "Devin CLI", "binary": "devin",
49
49
  "config": lambda h: Path(destinations("devin", h)[0]["path"]).parent, "agent": "devin", "integrations": ("devin-hooks",)},
50
- {"id": "uniflow", "label": "Uniflow", "binary": "uniflow", "config": uniflow_config_dir, "agent": "uniflow",
51
- "integrations": ()},
52
50
  {"id": "tmux", "label": "tmux", "binary": "tmux", "flag": "-V", "config": None, "agent": None,
53
51
  "integrations": ("tmux-status",)},
54
52
  )
55
- AGENT_ORDER = ("claude", "codex", "gemini", "devin", "uniflow") # natives first: the uniflow notice sees them
53
+ AGENT_ORDER = ("claude", "codex", "gemini", "devin")
56
54
  GUIDE_WORDS = {"install": "aplicado", "update": "atualizado", "replace": "atualizado", "unchanged": "já em dia",
57
55
  "uninstall": "removido", "remove": "removido"}
58
56
  BAD_SKILL = ("modified", "missing", "unsafe", "external_link")
@@ -81,10 +79,12 @@ def detect(home, probe=True):
81
79
  rows = []
82
80
  for cli in CLIS:
83
81
  binary = shutil.which(cli["binary"])
84
- config = cli["config"](home) if cli["config"] else None
82
+ config = None
85
83
  try:
86
- config_found = bool(config and config.exists())
87
- except OSError:
84
+ config = cli["config"](home) if cli["config"] else None
85
+ from .agents import physical
86
+ config_found = bool(config and Path(physical(config)).exists())
87
+ except (CrowError, OSError):
88
88
  config_found = False
89
89
  rows.append({"id": cli["id"], "label": cli["label"], "agent": cli["agent"], "binary": binary,
90
90
  "config": str(config) if config else None, "config_found": config_found,
@@ -149,72 +149,30 @@ def fix_for(code, agent):
149
149
  return hint
150
150
 
151
151
 
152
- DISABLED_REASON = "desabilitada no Uniflow (SKILL.md.disabled no store); não conta como ativa"
153
-
154
-
155
- def store_disabled(home, store):
156
- """Names managed by crow-skills in the Uniflow store that Uniflow disabled (SKILL.md.disabled)."""
157
- try:
158
- return {r["name"] for r in store.status(destinations("uniflow", home), ()) if r["status"] == "disabled"}
159
- except (CrowError, OSError):
160
- return set()
161
-
162
-
163
- def classify(store, target, names, agent, replace, overwrite, store_names=()):
164
- """Split the requested skills into (to install, kept with the reason and fix, projected by Uniflow).
165
-
166
- A skill Uniflow projects into this CLI (a link to its store) is installed for the CLI: its source of
167
- truth is the store (--agents uniflow); the link is never followed, replaced or reported as an error."""
152
+ def classify(store, target, names, agent, replace, overwrite):
153
+ """Select native regular packages, preserving edits and refusing individual links."""
168
154
  rows = {r["name"]: r for r in store.status([target], names) if r["name"]}
169
- install, kept, via, waiting = [], [], [], []
170
- # Uniflow already projects into this dir: what goes into its store arrives by the same projection on the
171
- # CLI's next start; a native copy would shadow it. crow-behaviour stays native when absent (guide, hooks).
172
- projected = any(r["status"] == "uniflow_link" for r in store.status([target], ()))
173
- pending = set(store_names) if projected else set()
174
- store_ok = guide.store_managed(store.home, store) if projected else set()
175
- disabled = store_disabled(store.home, store) if projected else set()
176
- broken = guide.store_broken(store.home, store) if projected else set()
155
+ install, kept = [], []
177
156
  for name in names:
178
- row = rows.get(name) or {"status": "absent"}
179
- status = row["status"]
180
- if status == "uniflow_link" and name in store_ok:
181
- via.append(name)
182
- elif status == "uniflow_link" and name in disabled:
183
- kept.append({"name": name, "status": "uniflow_link_disabled", "reason": DISABLED_REASON,
184
- "fix": "reabilite a skill no Uniflow; o crow-skills mantém a desabilitação"})
185
- elif status == "uniflow_link" and name in broken:
186
- kept.append({"name": name, "status": "uniflow_link_modified", "reason": guide.BROKEN_REASON,
187
- "fix": guide.broken_fix(name)})
188
- elif status == "uniflow_link":
189
- kept.append({"name": name, "status": "uniflow_link_unmanaged",
190
- "reason": "link do Uniflow para um pacote do store que o crow-skills não gerencia; não conta como instalada",
191
- "fix": "revise o store e, para gerenciá-lo com backup: crow-skills setup --agents uniflow --replace --yes"})
192
- elif status == "absent" and name in pending and name != "crow-behaviour":
193
- via.append(name)
194
- waiting.append(name)
195
- elif status in ("modified", "missing") and not overwrite:
157
+ status = (rows.get(name) or {"status": "absent"})["status"]
158
+ if status in ("modified", "missing") and not overwrite:
196
159
  kept.append({"name": name, "status": status, "reason": "editada localmente" if status == "modified" else "arquivos removidos",
197
160
  "fix": "crow-skills setup --agents " + agent + " --overwrite-modified --yes"})
198
161
  elif status == "unmanaged" and not replace:
199
162
  kept.append({"name": name, "status": status, "reason": "já existe e não é gerenciada pelo crow-skills",
200
163
  "fix": "crow-skills setup --agents " + agent + " --replace --yes"})
201
- elif status == "external_link" and not replace and not store.managed_target(
202
- os.path.realpath(Path(target["path"]) / name), name):
203
- kept.append({"name": name, "status": status, "reason": "link do Uniflow para uma cópia não gerenciada",
204
- "fix": "crow-skills setup --agents uniflow --replace --yes"})
205
164
  elif status == "unsafe":
206
165
  path = Path(target["path"]) / name
207
- what = "é um link para " + os.path.realpath(path) if path.is_symlink() else "não é um diretório regular"
208
- kept.append({"name": name, "status": status, "reason": str(path) + " " + what + "; não é lido nem substituído",
166
+ kept.append({"name": name, "status": status, "reason": str(path) + " não é um pacote regular; não é lido nem substituído",
209
167
  "fix": "remova ou troque você mesmo " + str(path) + " e rode crow-skills setup --yes de novo"})
210
168
  else:
211
169
  install.append(name)
212
- return install, kept, via, waiting
170
+ return install, kept
213
171
 
214
172
 
215
173
  def refresh_extras(agent, home, dry_run, trust=True, source=None, ref=None, replace=False, overwrite=False):
216
174
  """Guide, integrations and hooks of an agent when no skill is (re)installed there, e.g. every crow
217
- skill comes from Uniflow's projection. Same commands as guide/integrations install --global."""
175
+ skill is already installed. Same commands as guide/integrations install --global."""
218
176
  cli = _cli()
219
177
  out = {"guides": None, "integrations": None}
220
178
  if agent not in guide.MECHANISMS or "crow-behaviour" not in guide.managed_skills(agent, home, Store(home)):
@@ -235,19 +193,14 @@ def refresh_extras(agent, home, dry_run, trust=True, source=None, ref=None, repl
235
193
  return out
236
194
 
237
195
 
238
- def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
196
+ def configure_agent(args, home, store, agent, names, dry_run):
239
197
  cli = _cli()
240
198
  target = destinations(agent, home)[0]
241
- entry = {"agent": agent, "target": target["path"], "changed": [], "unchanged": [], "kept": [], "via_uniflow": [],
199
+ entry = {"agent": agent, "target": target["path"], "changed": [], "unchanged": [], "kept": [],
242
200
  "error": None, "guides": None, "integrations": None}
243
201
  replace, overwrite = bool(getattr(args, "replace", False)), bool(getattr(args, "overwrite_modified", False))
244
202
  try:
245
- install, entry["kept"], entry["via_uniflow"], waiting = classify(store, target, names, agent, replace, overwrite,
246
- store_names if agent != "uniflow" else ())
247
- if agent == "codex":
248
- install = codex_projection_step(args, home, store, entry, names, install, waiting, store_names, dry_run)
249
- if waiting:
250
- entry["via_uniflow_pending"] = waiting
203
+ install, entry["kept"] = classify(store, target, names, agent, replace, overwrite)
251
204
  if not install:
252
205
  entry.update(refresh_extras(agent, home, dry_run, not getattr(args, "no_trust", False), getattr(args, "source", None),
253
206
  getattr(args, "ref", None), replace, overwrite))
@@ -276,8 +229,6 @@ def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
276
229
  entry["guides"] = data.get("guides")
277
230
  entry["integrations"] = data.get("integrations")
278
231
  entry["transaction"] = data.get("transaction")
279
- if data.get("uniflow"):
280
- entry["uniflow"] = data["uniflow"]
281
232
  if agent == "codex":
282
233
  # Depois da instalação no diretório do Codex, nunca antes: a cópia legada só sai quando
283
234
  # existe uma instalação no lugar novo. Num dry run o disco não foi tocado, então o que este
@@ -287,9 +238,6 @@ def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
287
238
  return entry
288
239
 
289
240
 
290
- DUPLICATE = "removida: duplicava a projeção do Uniflow"
291
-
292
-
293
241
  def migrate_legacy(args, home, store, entry, dry_run, planned=()):
294
242
  """legacy_shared_step, but a failure there never costs the agent its result.
295
243
 
@@ -304,19 +252,9 @@ def migrate_legacy(args, home, store, entry, dry_run, planned=()):
304
252
 
305
253
 
306
254
  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.
255
+ """Migrate intact managed legacy copies only after native replacements exist.
256
+
257
+ Modified, foreign, unsafe and shared records are retained with evidence.
320
258
  """
321
259
  legacy = legacy_shared_dir(home)
322
260
  # Mesmo diretório (inclusive via symlink): não há nada para migrar e avisar seria ruído.
@@ -339,15 +277,10 @@ def legacy_shared_step(args, home, store, entry, dry_run, planned=()):
339
277
  entry["legacy_shared"] = {"state": "warn", "note": str(exc), "path": str(legacy)}
340
278
  return
341
279
  # What actually works in Codex's own dir right now: our intact install, or a healthy projection.
342
- from . import projection
343
280
  try:
344
281
  own = {r["name"]: r["status"] for r in store.status([destinations("codex", home)[0]], sorted(records)) if r["name"]}
345
282
  except (CrowError, OSError):
346
283
  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
284
  # One reason per name: a more specific one already recorded for this name (a broken projection,
352
285
  # an unsafe target) explains it better than anything this step would add.
353
286
  already = {k["name"] for k in entry.get("kept") or []}
@@ -359,12 +292,12 @@ def legacy_shared_step(args, home, store, entry, dry_run, planned=()):
359
292
  if name in already:
360
293
  deferred.append(name) # já explicado por uma razão mais específica deste mesmo nome
361
294
  continue
362
- if own.get(name) != "installed" and name not in healthy and name not in set(planned or ()):
295
+ if own.get(name) != "installed" and name not in set(planned or ()):
363
296
  kept.append({"name": name, "status": "legacy_shared_without_replacement",
364
297
  "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",
298
+ "este nome não tem instalação íntegra regular",
366
299
  "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"})
300
+ "--yes) e rode de novo"})
368
301
  continue
369
302
  others = set(records.get(name, {}).get("agents") or ["codex"]) - {"codex"}
370
303
  if others:
@@ -399,61 +332,6 @@ def legacy_shared_step(args, home, store, entry, dry_run, planned=()):
399
332
  "(com backup) agora que o codex instala no diretório dele"}
400
333
 
401
334
 
402
- def codex_projection_step(args, home, store, entry, names, install, waiting, store_names, dry_run):
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."""
408
- from . import projection
409
- for name, kind in sorted(projection.links(home, store).items()):
410
- if kind == "modified" and name in names:
411
- entry["kept"].append({"name": name, "status": "uniflow_link_modified",
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",
414
- "fix": guide.broken_fix(name)})
415
- projected = projection.projected("codex", home, store)
416
- if not projected:
417
- return install
418
- selected = set(names)
419
- # Names on their way into the store reach Codex by the same projection on its next start.
420
- pending = (set(store_names) - projected - {"crow-behaviour"}) & set(install)
421
- pending = {n for n in pending if n not in entry["unchanged"]}
422
- native = {r["name"]: r["status"] for r in store.status([destinations("codex", home)[0]], sorted(pending)) if r["name"]}
423
- pending = {n for n in pending if native.get(n, "absent") == "absent"}
424
- entry["via_uniflow"] = sorted(set(entry["via_uniflow"]) | (projected & selected) | pending)
425
- waiting.extend(sorted(pending))
426
- dupes = projection.duplicates(home, store)
427
- overwrite = bool(getattr(args, "overwrite_modified", False))
428
- remove = [n for n in dupes["remove"] + (dupes["modified"] if overwrite else []) if n in selected]
429
- entry["kept"] = [k for k in entry["kept"] if k["name"] not in projected]
430
- for name in dupes["modified"]:
431
- if name in selected and not overwrite:
432
- entry["kept"].append({"name": name, "status": "duplicate_modified",
433
- "reason": "cópia editada na pasta compartilhada antiga, que duplica a projeção do Uniflow no Codex",
434
- "fix": "revise e, para remover com backup: crow-skills setup --agents codex --overwrite-modified --yes"})
435
- for name in dupes["shared"]:
436
- if name in selected:
437
- entry["kept"].append({"name": name, "status": "duplicate_shared",
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",
440
- "fix": "se nenhum outro host precisa dela: crow-skills uninstall " + name +
441
- " --dest " + str(legacy_shared_dir(home)) + " --yes"})
442
- if remove:
443
- cli = _cli()
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),
447
- "--dry-run" if dry_run else "--yes"]
448
- if overwrite:
449
- argv.append("--overwrite-modified")
450
- argv.append("--no-integrations") # hooks/trust follow in the install/refresh step, with the run's --no-trust
451
- data = cli.run(cli.parser().parse_args(argv))
452
- entry["removed"] = [{"name": c["name"], "reason": DUPLICATE} for c in data["changes"] if c["action"] != "unchanged"]
453
- entry["removal_transaction"] = data.get("transaction")
454
- return [n for n in install if n not in projected and n not in pending]
455
-
456
-
457
335
  def machine_steps(args, home, store, dry_run):
458
336
  steps = {}
459
337
  # Crow mode: written as on only when the user never chose (no mode file).
@@ -589,10 +467,6 @@ def summarize(detected, entries, store, home, probe, dry_run, trust=True):
589
467
  parts.append(str(changed) + " " + verb)
590
468
  if unchanged:
591
469
  parts.append(str(unchanged) + " já em dia")
592
- if entry.get("via_uniflow"):
593
- parts.append(str(len(entry["via_uniflow"])) + " via uniflow")
594
- if entry.get("removed"):
595
- parts.append(str(len(entry["removed"])) + (" a remover" if dry_run else " removidas") + " (duplicavam o Uniflow)")
596
470
  if kept:
597
471
  parts.append(str(len(kept)) + " mantidas")
598
472
  legacy = entry.get("legacy_shared") or {}
@@ -609,9 +483,7 @@ def summarize(detected, entries, store, home, probe, dry_run, trust=True):
609
483
  guides = entry.get("guides") or {}
610
484
  guide_change = next((c for c in guides.get("changes", []) if c.get("agent") == cli["agent"]), None)
611
485
  guide_skip = next((s for s in guides.get("skipped", []) if s.get("agent") == cli["agent"]), None)
612
- if cli["agent"] == "uniflow":
613
- item["guide"] = "não se aplica"
614
- elif guide_change:
486
+ if guide_change:
615
487
  item["guide"] = GUIDE_WORDS.get(guide_change.get("action"), guide_change.get("action", "?"))
616
488
  elif guide_skip:
617
489
  item["guide"] = "não aplicado"
@@ -637,19 +509,6 @@ def summarize(detected, entries, store, home, probe, dry_run, trust=True):
637
509
  item["notes"].append("hooks: " + fix)
638
510
  for k in kept:
639
511
  item["notes"].append(k["name"] + " mantida (" + k["reason"] + "): " + k["fix"])
640
- if entry.get("via_uniflow_pending"):
641
- item["notes"].append(", ".join(entry["via_uniflow_pending"]) + ": vai para o store do Uniflow e aparece neste CLI no "
642
- "próximo início pelo Uniflow (sem cópia nativa, para não sombrear o store)")
643
- for dup in (entry.get("uniflow") or {}).get("native_duplicates") or []:
644
- names_ = ", ".join(sk["name"] for sk in dup["skills"])
645
- effect = ("o Codex vê as duas cópias" if dup.get("effect") == "both" else
646
- "o Uniflow não liga esse nome e a cópia nativa vale ali")
647
- item["notes"].append("cópia física em " + ",".join(dup["agents"]) + " (" + dup["target"] + ") de " + names_ +
648
- ", que o store também tem: " + effect + " (links do Uniflow não contam)")
649
- for command in (entry.get("uniflow") or {}).get("suggested_commands") or []:
650
- item["notes"].append("para usar só o store: " + command + " --yes" +
651
- (" (sem a cópia nativa da crow-behaviour, o guia daquele agente sai até o Uniflow ligar o store)"
652
- if " crow-behaviour " in command else ""))
653
512
  problems = [] if dry_run else agent_health(store, home, cli["agent"])
654
513
  if entry["error"]:
655
514
  item["state"] = "fail"
@@ -674,18 +533,7 @@ def setup(args, home, store, interactive):
674
533
  source, names = catalog_names(args)
675
534
 
676
535
  def build(dry):
677
- # The Uniflow store goes first: a name is left to its projection only if the store really has it
678
- # (installed, updated or already there); kept or failed names are installed natively.
679
- entries = {}
680
- store_names = ()
681
- if "uniflow" in agents:
682
- entries["uniflow"] = configure_agent(args, home, store, "uniflow", names, dry)
683
- if not entries["uniflow"]["error"]:
684
- store_names = tuple(entries["uniflow"]["changed"]) + tuple(entries["uniflow"]["unchanged"])
685
- for agent in agents:
686
- if agent != "uniflow":
687
- entries[agent] = configure_agent(args, home, store, agent, names, dry, store_names)
688
- entries = [entries[a] for a in agents]
536
+ entries = [configure_agent(args, home, store, agent, names, dry) for agent in agents]
689
537
  return entries, machine_steps(args, home, store, dry)
690
538
 
691
539
  entries, steps = build(True)
@@ -748,8 +596,7 @@ def doctor_reasons(doctor, trust=True):
748
596
 
749
597
  # --- upgrade -------------------------------------------------------------------------------
750
598
 
751
- AGENT_LABELS = {"claude": "Claude Code", "codex": "Codex CLI", "gemini": "Gemini CLI / Antigravity", "devin": "Devin CLI",
752
- "uniflow": "Uniflow"}
599
+ AGENT_LABELS = {"claude": "Claude Code", "codex": "Codex CLI", "gemini": "Gemini CLI / Antigravity", "devin": "Devin CLI"}
753
600
 
754
601
 
755
602
  def upgrade_summary(skills, dry_run):
@@ -757,22 +604,13 @@ def upgrade_summary(skills, dry_run):
757
604
  for s in skills:
758
605
  agent = s["agent"]
759
606
  item = {"agent": agent, "label": AGENT_LABELS.get(agent, agent), "changed": s.get("updated") or [],
760
- "unchanged": s.get("unchanged", 0), "kept": s.get("kept") or [], "via_uniflow": len(s.get("via_uniflow") or []),
607
+ "unchanged": s.get("unchanged", 0), "kept": s.get("kept") or [],
761
608
  "removed": [r["name"] for r in s.get("removed") or []],
762
609
  "needs_user": []}
763
610
  for k in item["kept"]:
764
611
  if k["status"] in ("modified", "missing"):
765
612
  item["needs_user"].append(k["name"] + " editada localmente; para substituir com backup: crow-skills update " +
766
613
  k["name"] + " --agents " + agent + " --global --overwrite-modified --yes")
767
- elif k["status"] == "duplicate_modified":
768
- item["needs_user"].append(k["name"] + " (cópia nativa editada) duplica a projeção do Uniflow no Codex; para remover "
769
- "com backup: crow-skills setup --agents codex --overwrite-modified --yes")
770
- elif k["status"] == "uniflow_link_modified":
771
- item["needs_user"].append(k["name"] + ": a entrada do store do Uniflow foi editada ou perdeu arquivos; para "
772
- "restaurá-la com backup: " + k.get("fix", "crow-skills update " + k["name"] +
773
- " --agents uniflow --global --overwrite-modified --yes").split(": ", 1)[-1])
774
- elif k["status"] == "duplicate_shared":
775
- item["needs_user"].append(k["name"] + " duplica a projeção do Uniflow no Codex, mas também é de shared; mantida")
776
614
  elif k["status"] == "other_origin":
777
615
  item["needs_user"].append(k["name"] + " instalada de outra origem; para trocar: crow-skills install " +
778
616
  k["name"] + " --agents " + agent + " --global --yes")
@@ -881,7 +719,7 @@ def render_setup(data, stream=None):
881
719
  lines.append("")
882
720
  if data.get("nothing_detected"):
883
721
  lines.append(ui.mark("warn", stream) + " Nenhum CLI de agente encontrado. Instale um (claude, codex, gemini, agy, devin, "
884
- "uniflow) e rode crow-skills setup de novo.")
722
+ "devin) e rode crow-skills setup de novo.")
885
723
  elif data.get("dry_run"):
886
724
  lines.append("Para aplicar: crow-skills setup --yes")
887
725
  elif data.get("healthy"):
@@ -914,7 +752,7 @@ def render_upgrade(data, stream=None):
914
752
  for item in data.get("summary", []):
915
753
  changed = ", ".join(item["changed"]) if len(item["changed"]) <= 3 else str(len(item["changed"])) + " skills"
916
754
  removed = ((" · " if changed else "") + str(len(item["removed"])) + (" a remover" if dry else " removidas") +
917
- " (duplicavam o Uniflow)") if item.get("removed") else ""
755
+ " (cópias legadas)") if item.get("removed") else ""
918
756
  fresh = item.get("installed_new") or []
919
757
  # Dito na própria linha do CLI: uma skill nova do catálogo não é "atualizada", é instalada
920
758
  # agora — e foi exatamente isso que faltou quando a 0.17.0 publicou a crow-be-human.
@@ -923,10 +761,10 @@ def render_upgrade(data, stream=None):
923
761
  rows.append([ui.mark(STATE_MARK[item["state"]], stream), item["label"],
924
762
  ((("a atualizar: " if dry else "atualizadas: ") + changed) if changed else ("" if removed or new_text else "sem mudanças"))
925
763
  + removed + new_text,
926
- str(item["unchanged"]), str(item.get("via_uniflow", 0)), str(len(item["kept"]))])
764
+ str(item["unchanged"]), str(len(item["kept"]))])
927
765
  if rows:
928
766
  lines.append("")
929
- lines += ui.table(rows, ["", "CLI", "mudanças", "em dia", "via uniflow", "mantidas"], stream)
767
+ lines += ui.table(rows, ["", "CLI", "mudanças", "em dia", "mantidas"], stream)
930
768
  elif not data.get("handoff"):
931
769
  lines += ["", " Nenhum agente com skills gerenciadas; comece com: crow-skills setup --yes"]
932
770
  needs = [(i["label"], n) for i in data.get("summary", []) for n in i["needs_user"]]
@@ -1,127 +1,15 @@
1
- """Uniflow's projection of its store into Codex's own skills dir ($CODEX_HOME/skills, default ~/.codex/skills).
2
-
3
- Since 0.15.0 crow-skills installs for codex in that same directory, like it does for every other CLI, so a
4
- managed copy and a projected link can no longer carry the same name in two places. Codex still scans
5
- ~/.agents/skills as well, so a copy left there by an older version is still listed twice: those legacy copies
6
- are what duplicates() reports, and removing them is the migration away from the shared folder.
7
-
8
- A link counts as the codex install of a skill ("via uniflow") only when it resolves to <store>/<name> and
9
- that store entry is crow-skills-managed (guide.store_managed); a store entry Uniflow disabled is reported as
10
- disabled; a link to anything else is ignored. A link ~/.codex/skills/crow-x -> ~/.agents/skills/crow-x is an
11
- alias Codex deduplicates by canonical path: never altered nor removed. Note that the LINK being untouched
12
- does not make the command succeed — that name now lives in the codex destination, and a symlinked entry
13
- there cannot be managed, so install/setup report it (status "unsafe") and exit 1 until the user resolves it.
14
- """
1
+ """Generic migration of pre-0.15 native Codex copies from ~/.agents/skills."""
15
2
  import os
16
- from pathlib import Path
17
-
18
- from .agents import codex_home, codex_skills_dir, destinations, legacy_shared_dir, real_home, uniflow_store
3
+ from .agents import destinations, legacy_shared_dir
19
4
  from .source import CrowError
20
5
 
21
-
22
- def links(home, store):
23
- """{name: "managed" | "disabled" | "modified" | "unmanaged" | "alias" | "other"} for crow-* symlinks in Codex's own
24
- skills dir: to an intact managed / Uniflow-disabled / edited-or-incomplete managed / hand-written store entry, to our
25
- legacy shared copy, or elsewhere. Only "managed" counts as the codex install and allows removing a legacy
26
- duplicate. "alias" is a link into the legacy ~/.agents/skills: Codex deduplicates it by canonical path, and
27
- crow-skills never alters it — but it occupies the name in the destination, so that name is reported
28
- unmanageable rather than installed."""
29
- directory = codex_skills_dir(home)
30
- try:
31
- if directory.is_symlink() or not directory.is_dir():
32
- return {}
33
- entries = [p for p in directory.iterdir() if p.name.startswith("crow-") and p.is_symlink()]
34
- except OSError:
35
- return {}
36
- if not entries:
37
- return {}
38
- store_dir = uniflow_store(home)
39
- # The alias case is a link into the LEGACY shared folder, never into Codex's own dir (where the link
40
- # itself lives): comparing against the destination would now test a link against itself.
41
- legacy = legacy_shared_dir(home)
42
- from .guide import store_statuses
43
- statuses = store_statuses(home, store) # only an intact ("installed") store entry is a working codex install
44
- result = {}
45
- for link in entries:
46
- name = link.name
47
- resolved = os.path.realpath(link)
48
- if resolved == os.path.realpath(legacy / name):
49
- result[name] = "alias"
50
- elif (resolved == os.path.realpath(store_dir / name) and (store_dir / name).is_dir()
51
- and not (store_dir / name).is_symlink()):
52
- state = statuses.get(name)
53
- result[name] = {"installed": "managed", "disabled": "disabled", "modified": "modified",
54
- "missing": "modified"}.get(state, "unmanaged")
55
- else:
56
- result[name] = "other"
57
- return result
58
-
59
-
60
- def status_rows(home, store, rows):
61
- """Rows for `status`/`doctor`: Codex's view through Uniflow's links, classified.
62
-
63
- Codex's own dir IS the codex destination since 0.15.0, so `store.status()` already lists these
64
- links (as a bare `uniflow_link`). These rows ENRICH the existing one for each name — managed,
65
- disabled, edited or unmanaged, each with its reason — and are only appended when the base scan
66
- did not report the name. Appending unconditionally would count every projected skill twice.
67
- """
68
- directory = str(codex_skills_dir(home))
69
- extra = []
70
- for name, kind in sorted(links(home, store).items()):
71
- if kind == "managed":
72
- extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "uniflow_link", "revision": None,
73
- "activation_verified": False, "projection": "codex_home",
74
- "note": "Link do Uniflow em " + directory + " para o store; é a instalação do codex (via uniflow)."})
75
- elif kind == "disabled":
76
- extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "disabled", "revision": None,
77
- "activation_verified": False, "projection": "uniflow_link_disabled",
78
- "note": "Desabilitada no Uniflow (SKILL.md.disabled no store); reabilite no Uniflow para usar."})
79
- elif kind == "modified":
80
- from .guide import BROKEN_REASON, broken_fix
81
- extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "modified", "revision": None,
82
- "activation_verified": False, "projection": "uniflow_link_modified",
83
- "note": BROKEN_REASON[0].upper() + BROKEN_REASON[1:] + ". Para corrigir: " + broken_fix(name)})
84
- elif kind == "unmanaged":
85
- extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "unmanaged", "revision": None,
86
- "activation_verified": False, "projection": "uniflow_link_unmanaged",
87
- "note": "Link do Uniflow para um pacote do store que o crow-skills não gerencia; não conta como instalada. "
88
- "Revise o store e, para gerenciá-lo com backup: crow-skills setup --agents uniflow --replace --yes"})
89
- return merge_rows(rows, extra, directory)
90
-
91
-
92
- def merge_rows(rows, extra, directory):
93
- """Each enriched row replaces the base row of the same name in the same directory; the rest is
94
- appended. Returns the merged list (the caller keeps its own list object)."""
95
- merged, used = [], set()
96
- for row in rows:
97
- richer = next((e for e in extra if e["name"] == row.get("name") and row.get("target") == directory), None)
98
- if richer is None:
99
- merged.append(row)
100
- continue
101
- used.add(richer["name"])
102
- merged.append(dict(row, **{k: v for k, v in richer.items() if k != "revision" or row.get("revision") is None}))
103
- merged += [e for e in extra if e["name"] not in used]
104
- return merged
105
-
106
-
107
- def projected(agent, home, store):
108
- """Names Codex already sees through a managed Uniflow projection (empty for other agents)."""
109
- if agent != "codex":
110
- return set()
111
- return {n for n, kind in links(home, store).items() if kind == "managed"}
112
-
113
-
114
6
  def native_target(agent, target, home):
115
7
  """target is the user-scope codex install dir (not --project/--dest)."""
116
8
  return agent == "codex" and os.path.abspath(target["path"]) == os.path.abspath(destinations("codex", home)[0]["path"])
117
9
 
118
10
 
119
11
  def legacy_leftovers(home, store):
120
- """Names crow-skills still has installed in the legacy shared folder, INDEPENDENT of Uniflow.
121
-
122
- duplicates() answers "what duplicates a projection", so on a machine without Uniflow it is
123
- always empty — and an install made by 0.14.0 (which put codex's copies in that folder) would be
124
- invisible to status/doctor while Codex kept loading it. This is the signal for that."""
12
+ """Names still managed in the former shared native destination."""
125
13
  legacy = legacy_shared_dir(home)
126
14
  if not legacy.is_dir() or legacy.is_symlink():
127
15
  return []
@@ -135,36 +23,3 @@ def legacy_leftovers(home, store):
135
23
  return []
136
24
  return sorted(n for n, r in rows.items() if r["status"] in ("installed", "modified", "missing"))
137
25
 
138
-
139
- def duplicates(home, store):
140
- """Copies left in the legacy shared folder (~/.agents/skills) that Codex lists a second time.
141
-
142
- Codex scans its own dir and ~/.agents/skills; until 0.15.0 crow-skills installed for codex in the
143
- latter. A copy still there duplicates what Uniflow projects into Codex's own dir, so it is the thing
144
- to migrate away. Returns {"remove": [...], "modified": [...], "shared": [...]}: "remove" are
145
- crow-skills-managed and intact; "modified" were edited locally (kept unless --overwrite-modified);
146
- "shared" are also recorded for another agent of that dir, which may rely on them."""
147
- names = projected("codex", home, store)
148
- result = {"remove": [], "modified": [], "shared": []}
149
- if not names:
150
- return result
151
- legacy = legacy_shared_dir(home)
152
- if not legacy.is_dir() or legacy.is_symlink():
153
- return result
154
- target = {"path": str(legacy), "agents": ["codex"]}
155
- try:
156
- rows = {r["name"]: r for r in store.status([target], sorted(names)) if r["name"]}
157
- records = store.state(target["path"])["installed"]
158
- except (CrowError, OSError):
159
- return result
160
- for name in sorted(names):
161
- row = rows.get(name)
162
- if not row or row["status"] not in ("installed", "modified", "missing"):
163
- continue # absent, unmanaged or unreadable: nothing of ours to remove
164
- if set(records.get(name, {}).get("agents") or ["codex"]) - {"codex"}:
165
- result["shared"].append(name)
166
- elif row["status"] == "installed":
167
- result["remove"].append(name)
168
- else:
169
- result["modified"].append(name)
170
- return result