@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.
- package/README.md +2 -2
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/agents.py +35 -2
- package/crow_skills/autoupdate.py +17 -4
- package/crow_skills/broadcast.py +207 -28
- package/crow_skills/cli.py +54 -22
- package/crow_skills/guide.py +0 -1
- package/crow_skills/hook.py +54 -23
- package/crow_skills/machine.py +143 -11
- package/crow_skills/projection.py +69 -27
- package/crow_skills/resources.py +5 -3
- package/crow_skills/storage.py +9 -1
- package/crow_skills/ui.py +1 -1
- package/docs/cli.md +23 -11
- package/package.json +1 -1
- package/release.json +19 -19
- package/skills/crow-behaviour/SKILL.md +1 -1
- package/skills/crow-behaviour/references/broadcast.md +8 -7
package/crow_skills/cli.py
CHANGED
|
@@ -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
|
-
#
|
|
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": "
|
|
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
|
|
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:
|
|
769
|
-
"
|
|
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 #
|
|
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
|
|
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
|
|
955
|
-
"message": "O Codex vê " + str(len(names_)) + " skill(s) duas vezes
|
|
956
|
-
"
|
|
957
|
-
|
|
958
|
-
|
|
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
|
|
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
|
|
1068
|
-
"por install; remova-as com o
|
|
1069
|
-
|
|
1070
|
-
|
|
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:
|
package/crow_skills/guide.py
CHANGED
|
@@ -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.
|
package/crow_skills/hook.py
CHANGED
|
@@ -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
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
747
|
-
|
|
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
|
|
845
|
-
flags.add(
|
|
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:
|
package/crow_skills/machine.py
CHANGED
|
@@ -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
|
|
286
|
-
|
|
287
|
-
managed
|
|
288
|
-
|
|
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 + ";
|
|
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
|
|
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
|
|
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
|
-
" --
|
|
441
|
+
" --dest " + str(legacy_shared_dir(home)) + " --yes"})
|
|
322
442
|
if remove:
|
|
323
443
|
cli = _cli()
|
|
324
|
-
|
|
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)
|