@andespindola/crow-skills 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/agents.py +138 -4
- package/crow_skills/autoupdate.py +17 -4
- package/crow_skills/broadcast.py +138 -20
- package/crow_skills/cli.py +79 -24
- package/crow_skills/guide.py +10 -3
- package/crow_skills/hook.py +32 -14
- 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 +96 -11
- package/crow_skills/ui.py +1 -1
- package/docs/cli.md +35 -11
- package/package.json +1 -1
- package/release.json +16 -16
package/crow_skills/cli.py
CHANGED
|
@@ -18,7 +18,8 @@ from . import projection
|
|
|
18
18
|
from . import resources
|
|
19
19
|
from . import machine
|
|
20
20
|
from . import ui
|
|
21
|
-
from .agents import AGENTS, destinations, inventory
|
|
21
|
+
from .agents import (AGENTS, destinations, inventory, legacy_shared_dir,
|
|
22
|
+
refuse_implausible_destination, refuse_store_as_cli_dir)
|
|
22
23
|
from .source import CrowError, Source, valid_name
|
|
23
24
|
from .storage import Store, checked
|
|
24
25
|
|
|
@@ -166,6 +167,12 @@ def parser():
|
|
|
166
167
|
help="Não aplicar/remover as integrações de interface (status line, tmux) em --global")
|
|
167
168
|
if name != "uninstall":
|
|
168
169
|
child.add_argument("--no-trust", action="store_true", help=TRUST_HELP)
|
|
170
|
+
if name == "uninstall":
|
|
171
|
+
# Interno: a migração da pasta compartilhada roda como um uninstall logo depois de um
|
|
172
|
+
# install, e precisa ficar atrelada a ele para que um rollback do install desfaça os dois.
|
|
173
|
+
child.add_argument("--linked-to", dest="linked_to", metavar="TXID", default=argparse.SUPPRESS,
|
|
174
|
+
help=argparse.SUPPRESS) # SUPPRESS: ausente não cria atributo, então
|
|
175
|
+
# uma invocação existente continua parseando com exatamente o mesmo conjunto de atributos.
|
|
169
176
|
child = command_parser(sub, "guide", [common])
|
|
170
177
|
child.add_argument("action", choices=("install", "status", "uninstall"))
|
|
171
178
|
child.add_argument("--replace", action="store_true", help="Autorizar backup e substituição de conteúdo não gerenciado no caminho do guia")
|
|
@@ -255,7 +262,11 @@ def scope(args, home, interactive=False, read=False):
|
|
|
255
262
|
agents = ",".join(AGENTS)
|
|
256
263
|
elif interactive:
|
|
257
264
|
agents = prompt("Agentes separados por vírgula (" + ",".join(AGENTS) + "):")
|
|
258
|
-
|
|
265
|
+
targets = destinations(agents, home, project, custom)
|
|
266
|
+
if not read:
|
|
267
|
+
refuse_store_as_cli_dir(targets, home)
|
|
268
|
+
refuse_implausible_destination(targets, home)
|
|
269
|
+
return targets
|
|
259
270
|
|
|
260
271
|
|
|
261
272
|
def authorize(args, interactive, summary):
|
|
@@ -351,6 +362,22 @@ def guide_sync(args, home, store, command, targets, plan, source):
|
|
|
351
362
|
report["skipped"].append({"agent": agent, "reason": "crow-behaviour ausente; guia mantido. Confira crow-skills guide status."})
|
|
352
363
|
elif not record and command != "uninstall":
|
|
353
364
|
report["skipped"].append({"agent": agent, "reason": "crow-behaviour não ficará instalada para " + agent + "; guia não aplicado."})
|
|
365
|
+
# Um guia que não pode ser gravado não derruba a instalação das skills. Desde que o guia também
|
|
366
|
+
# resolve o caminho físico, o caso que sobra aqui é a área PROTEGIDA (prompt compartilhado,
|
|
367
|
+
# config do Uniflow), que `guide.protect()` recusa com protected_path: aquele agente entra em
|
|
368
|
+
# "skipped", com o motivo, e o resto segue.
|
|
369
|
+
for agent in list(actions):
|
|
370
|
+
mechanism = guide.MECHANISMS.get(agent)
|
|
371
|
+
if not mechanism:
|
|
372
|
+
continue
|
|
373
|
+
try:
|
|
374
|
+
guide.protect(Path(home) / mechanism["path"], home)
|
|
375
|
+
except CrowError as exc:
|
|
376
|
+
actions.pop(agent, None)
|
|
377
|
+
report["skipped"].append({"agent": agent, "code": exc.code,
|
|
378
|
+
"reason": str(exc) + " O guia não foi aplicado; as skills seguem. "
|
|
379
|
+
"Grave o guia você mesmo no caminho físico, ou use --no-guide "
|
|
380
|
+
"para não voltar a tentar."})
|
|
354
381
|
if not actions:
|
|
355
382
|
return report, None
|
|
356
383
|
if source is None and "install" in actions.values():
|
|
@@ -367,9 +394,9 @@ def guide_sync(args, home, store, command, targets, plan, source):
|
|
|
367
394
|
return report, gplan
|
|
368
395
|
|
|
369
396
|
|
|
370
|
-
def apply_chain(store, plan, gplan, home):
|
|
397
|
+
def apply_chain(store, plan, gplan, home, linked=None):
|
|
371
398
|
"""Skills first, then the guide; a failed guide rolls the skills back."""
|
|
372
|
-
txid = store.apply(plan)
|
|
399
|
+
txid = store.apply(plan, linked=linked)
|
|
373
400
|
if gplan is None:
|
|
374
401
|
return txid, None
|
|
375
402
|
try:
|
|
@@ -567,7 +594,9 @@ def broadcast_view(args, home, owner=None):
|
|
|
567
594
|
cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
|
|
568
595
|
owner = owner if owner is not None else broadcast_owner(args, home, cwd)
|
|
569
596
|
me_key = owner["key"] if owner else None
|
|
570
|
-
|
|
597
|
+
# Aqui a identidade forte é de graça: este caminho já é um comando do usuário, não o hook.
|
|
598
|
+
# Um único ps para a máquina distingue "pid vivo" de "número reciclado" e derruba zumbi.
|
|
599
|
+
view = broadcast.overview(home, me_key, cwd=cwd, table=broadcast.process_table())
|
|
571
600
|
if view["me"] is not None and view["me"].get("cwd") is None:
|
|
572
601
|
view["me"]["cwd"] = cwd
|
|
573
602
|
view["owner"] = owner
|
|
@@ -716,24 +745,31 @@ def resync_integrations(home, store):
|
|
|
716
745
|
pass # Reported by status/doctor; never turns a completed rollback into a failure.
|
|
717
746
|
|
|
718
747
|
|
|
748
|
+
def linked_transactions(store, txid):
|
|
749
|
+
"""Committed transactions that COMPLETE the given one and must be undone before it: the guide,
|
|
750
|
+
and the uninstall that migrated the legacy shared folder after an install."""
|
|
751
|
+
return [t["id"] for t in store.transactions()
|
|
752
|
+
if t.get("linked") == txid and t["phase"] == "committed"]
|
|
753
|
+
|
|
754
|
+
|
|
719
755
|
def linked_guides(store, txid):
|
|
720
756
|
"""Committed guide transactions that completed the given skills transaction."""
|
|
721
757
|
return [t["id"] for t in store.transactions() if t.get("kind") == "guide" and t.get("linked") == txid and t["phase"] == "committed"]
|
|
722
758
|
|
|
723
759
|
|
|
724
760
|
# What a native copy means once Uniflow links its store into each CLI's user skills dir.
|
|
725
|
-
#
|
|
761
|
+
# Every CLI, codex included since 0.15.0, has its skills installed in its OWN dir, the same one
|
|
762
|
+
# Uniflow links into, so a native copy of a name always just wins there (no second directory).
|
|
726
763
|
NATIVE_EFFECT = {
|
|
727
764
|
"claude": "skipped", "gemini": "skipped", "devin": "skipped",
|
|
728
|
-
"codex": "
|
|
765
|
+
"codex": "skipped",
|
|
729
766
|
}
|
|
730
767
|
|
|
731
768
|
|
|
732
769
|
def uniflow_notice(home, store, targets, plan):
|
|
733
770
|
"""Installing into the Uniflow store while native crow-* copies remain. Uniflow does
|
|
734
771
|
not link a name a CLI already has in the same dir, so that copy (possibly another
|
|
735
|
-
revision) wins there
|
|
736
|
-
link, so it sees both. Report it and the explicit command; never remove anything."""
|
|
772
|
+
revision) wins there. Report it and the explicit command; never remove anything."""
|
|
737
773
|
if not any("uniflow" in t["agents"] for t in targets):
|
|
738
774
|
return None
|
|
739
775
|
names = sorted({c["name"] for c in plan["changes"] if "uniflow" in c["agents"]})
|
|
@@ -765,8 +801,9 @@ def uniflow_notice(home, store, targets, plan):
|
|
|
765
801
|
for names_, agents_ in groups.items()]
|
|
766
802
|
return {"native_duplicates": list(native.values()), "uninspected": uninspected,
|
|
767
803
|
"note": "effect=skipped: o Uniflow não cria o link de um nome que o agente já tem nativo, e essa cópia vale no lugar do "
|
|
768
|
-
"store. effect=both:
|
|
769
|
-
"
|
|
804
|
+
"store. Desde a 0.15.0 nenhum destino produz effect=both: todo CLI instala no diretório dele, o mesmo em que o "
|
|
805
|
+
"Uniflow projeta o store, então não há uma segunda raiz em que o mesmo nome apareça de novo. Nada foi removido. "
|
|
806
|
+
"Remover a cópia nativa de crow-behaviour remove também o guia daquele agente.",
|
|
770
807
|
"suggested_commands": commands, "suggested_command": " && ".join(commands) if commands else None}
|
|
771
808
|
|
|
772
809
|
|
|
@@ -803,7 +840,7 @@ def codex_projection_split(home, store, targets, names):
|
|
|
803
840
|
"""The user-scope codex target and which of the names Codex already sees via Uniflow (install skips them)."""
|
|
804
841
|
target = next((t for t in targets if projection.native_target("codex", t, home)), None)
|
|
805
842
|
if target is None or set(target["agents"]) - {"codex"}:
|
|
806
|
-
return None #
|
|
843
|
+
return None # a dir shared with another agent is not Codex's own projected dir
|
|
807
844
|
projected = projection.projected("codex", home, store)
|
|
808
845
|
if not projected:
|
|
809
846
|
return None
|
|
@@ -946,16 +983,30 @@ def run(args):
|
|
|
946
983
|
"mode": hook.read_mode(home), "activation_verified": False,
|
|
947
984
|
"reload_hint": "Peça ao host para recarregar/listar skills; reinicie a sessão se necessário."}
|
|
948
985
|
if any(projection.native_target("codex", t, home) for t in targets):
|
|
949
|
-
rows
|
|
986
|
+
rows[:] = projection.status_rows(home, store, rows) # enriquece no lugar; += contaria duas vezes
|
|
950
987
|
dupes = projection.duplicates(home, store)
|
|
951
988
|
names_ = dupes["remove"] + dupes["modified"] + dupes["shared"]
|
|
952
989
|
if names_:
|
|
953
990
|
# A warning, not unhealthy: both copies are intact and work; Codex just lists them twice.
|
|
954
|
-
result
|
|
955
|
-
"message": "O Codex vê " + str(len(names_)) + " skill(s) duas vezes
|
|
956
|
-
"
|
|
957
|
-
|
|
958
|
-
|
|
991
|
+
result.setdefault("warnings", []).append({"code": "codex_duplicates", "skills": names_,
|
|
992
|
+
"message": "O Codex vê " + str(len(names_)) + " skill(s) duas vezes: cópia antiga na pasta "
|
|
993
|
+
"compartilhada " + str(legacy_shared_dir(home)) + " e a instalação em "
|
|
994
|
+
+ str(projection.codex_skills_dir(home)) + ". O crow-skills não usa mais a pasta "
|
|
995
|
+
"compartilhada; remover as cópias de lá encerra a duplicação.",
|
|
996
|
+
"fix": "crow-skills uninstall " + ",".join(names_) + " --dest "
|
|
997
|
+
+ str(legacy_shared_dir(home)) + " --yes"
|
|
998
|
+
+ (" --overwrite-modified" if dupes["modified"] else "")})
|
|
999
|
+
leftovers = [n for n in projection.legacy_leftovers(home, store) if n not in names_]
|
|
1000
|
+
if leftovers:
|
|
1001
|
+
# Sem Uniflow nada disso duplica uma projeção, então o aviso acima nunca apareceria
|
|
1002
|
+
# e uma instalação feita pela 0.14.0 ficaria invisível aqui, ainda carregada pelo Codex.
|
|
1003
|
+
result.setdefault("warnings", []).append(
|
|
1004
|
+
{"code": "codex_legacy_shared", "skills": leftovers,
|
|
1005
|
+
"message": "O crow-skills ainda tem " + str(len(leftovers)) + " skill(s) instalada(s) na pasta "
|
|
1006
|
+
"compartilhada antiga " + str(legacy_shared_dir(home)) + ", que esta versão não usa mais; o "
|
|
1007
|
+
"Codex continua carregando de lá.",
|
|
1008
|
+
"fix": "crow-skills setup --agents codex --yes (instala no diretório do Codex e migra), ou "
|
|
1009
|
+
"crow-skills uninstall " + ",".join(leftovers) + " --dest " + str(legacy_shared_dir(home)) + " --yes"})
|
|
959
1010
|
result["groups"] = group_coverage(rows)
|
|
960
1011
|
try:
|
|
961
1012
|
result["autoupdate"] = autoupdate.status(store, home) if global_view else None
|
|
@@ -983,7 +1034,7 @@ def run(args):
|
|
|
983
1034
|
# on recover, an interrupted guide also undoes the skills it completed.
|
|
984
1035
|
follow = []
|
|
985
1036
|
if args.command == "rollback":
|
|
986
|
-
before_ids = [g for txid in ids for g in
|
|
1037
|
+
before_ids = [g for txid in ids for g in linked_transactions(store, txid)]
|
|
987
1038
|
ids, transactions = before_ids + ids, [store.journal(t) for t in before_ids] + transactions
|
|
988
1039
|
else:
|
|
989
1040
|
follow = [t["linked"] for t in transactions if t.get("linked")
|
|
@@ -1064,15 +1115,19 @@ def run(args):
|
|
|
1064
1115
|
summary["codex_projection"] = {
|
|
1065
1116
|
"via_uniflow": sorted(codex_view["skip"]), "duplicates": codex_view["duplicates"],
|
|
1066
1117
|
"note": "O Codex já vê estas skills pelo link do Uniflow em " + str(projection.codex_skills_dir(home)) +
|
|
1067
|
-
"; nenhuma cópia
|
|
1068
|
-
"por install; remova-as com o
|
|
1069
|
-
|
|
1070
|
-
|
|
1118
|
+
"; nenhuma cópia é criada para o codex. As cópias que ficaram na pasta compartilhada antiga "
|
|
1119
|
+
+ str(legacy_shared_dir(home)) + " duplicam a projeção e não são removidas por install; remova-as com o "
|
|
1120
|
+
"comando sugerido ou rode crow-skills setup --yes.",
|
|
1121
|
+
# --dest, nunca --agents codex: esses nomes, no diretório do Codex, são links do Uniflow, e
|
|
1122
|
+
# o uninstall ali recusaria com unsafe_destination.
|
|
1123
|
+
"suggested_command": ("crow-skills uninstall " + " ".join(codex_view["duplicates"]) + " --dest "
|
|
1124
|
+
+ str(legacy_shared_dir(home)) + " --yes") if codex_view["duplicates"] else None}
|
|
1071
1125
|
if plan["items"] or (gplan and gplan["items"]) or ui.get("changes"):
|
|
1072
1126
|
authorize(args, interactive, summary)
|
|
1073
1127
|
txid = None
|
|
1074
1128
|
if not getattr(args, "dry_run", False):
|
|
1075
|
-
txid, guides["transaction"] = apply_chain(store, plan, gplan, home
|
|
1129
|
+
txid, guides["transaction"] = apply_chain(store, plan, gplan, home,
|
|
1130
|
+
linked=getattr(args, "linked_to", None))
|
|
1076
1131
|
if not ui.get("disabled"):
|
|
1077
1132
|
# Best effort after the skills/guide transactions; a refusal is reported, never undoes them.
|
|
1078
1133
|
try:
|
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.
|
|
@@ -120,8 +119,16 @@ def shielded(path, home):
|
|
|
120
119
|
|
|
121
120
|
|
|
122
121
|
def protect(path, home):
|
|
123
|
-
"""Refuse
|
|
124
|
-
|
|
122
|
+
"""Refuse shared prompts and anything inside their targets, on the PHYSICAL path.
|
|
123
|
+
|
|
124
|
+
Resolve first (0.16.0, decisão do dono): o arquivo de guia e as integrações de um CLI cujo
|
|
125
|
+
diretório vive em dotfiles ficavam de fora, e o CLI terminava instalado sem guia, sem hooks e
|
|
126
|
+
sem /crow — a skill ficava lá sem o agente carregá-la por iniciativa própria. `checked()`
|
|
127
|
+
continua recusando escrever ATRAVÉS de symlink, agora sobre o caminho real, e `shielded()`
|
|
128
|
+
passa a comparar o destino real, o que torna a proteção de prompt compartilhado mais precisa.
|
|
129
|
+
"""
|
|
130
|
+
from .agents import physical
|
|
131
|
+
path = checked(physical(path))
|
|
125
132
|
if shielded(path, home):
|
|
126
133
|
raise CrowError("protected_path", "Arquivo de prompt compartilhado (ou alvo dele) não é gravado: " + str(path))
|
|
127
134
|
return path
|
package/crow_skills/hook.py
CHANGED
|
@@ -239,13 +239,20 @@ def broadcast_module():
|
|
|
239
239
|
return _BROADCAST or None
|
|
240
240
|
|
|
241
241
|
|
|
242
|
-
def resource_hint(home):
|
|
243
|
-
"""The one-line memory hint, or None (no pressure, no probe, or any failure).
|
|
242
|
+
def resource_hint(home, write_through=True):
|
|
243
|
+
"""The one-line memory hint, or None (no pressure, no probe, or any failure).
|
|
244
|
+
|
|
245
|
+
write_through=False for `--probe`: the reading is still taken, but a cache miss must not write
|
|
246
|
+
resources-cache.json, or "exercise the hook without writing anything" would be false."""
|
|
244
247
|
module = resources_module()
|
|
245
248
|
if module is None:
|
|
246
249
|
return None
|
|
247
250
|
try:
|
|
248
|
-
|
|
251
|
+
try:
|
|
252
|
+
reading = module.probe(home, processes=False, use_cache=True, write_through=write_through)
|
|
253
|
+
except TypeError: # crow_resources.py copy older than this hook
|
|
254
|
+
reading = module.probe(home, processes=False, use_cache=True)
|
|
255
|
+
text = module.hint(reading)
|
|
249
256
|
if text and hasattr(module, "orphan_hint"):
|
|
250
257
|
text += module.orphan_hint(home) # last orphan scan, a file read: no ps call per prompt
|
|
251
258
|
return text
|
|
@@ -705,7 +712,7 @@ def check_only(host, raw, payload, home):
|
|
|
705
712
|
return broadcast_check(host, raw, home, module, me, touched, tree)
|
|
706
713
|
|
|
707
714
|
|
|
708
|
-
def handle(host, raw, payload, home, env=None, check=False):
|
|
715
|
+
def handle(host, raw, payload, home, env=None, check=False, probe=False):
|
|
709
716
|
"""Process one hook call; returns the JSON to print (or None). Never raises to the caller."""
|
|
710
717
|
env = os.environ if env is None else env
|
|
711
718
|
if host not in HOSTS or not isinstance(payload, dict) or foreign(host, payload, env, home):
|
|
@@ -714,7 +721,7 @@ def handle(host, raw, payload, home, env=None, check=False):
|
|
|
714
721
|
return check_only(host, raw, payload, home)
|
|
715
722
|
event = normalise(host, raw, payload)
|
|
716
723
|
current = read_mode(home)
|
|
717
|
-
if raw in PROMPT_EVENTS:
|
|
724
|
+
if raw in PROMPT_EVENTS and not probe:
|
|
718
725
|
text = prompt_text(payload)
|
|
719
726
|
match = COMMAND.match(text) if text and len(text) < 64 else None
|
|
720
727
|
if match and host in ("codex", "devin", "agy"):
|
|
@@ -756,8 +763,17 @@ def handle(host, raw, payload, home, env=None, check=False):
|
|
|
756
763
|
module = None
|
|
757
764
|
except Exception: # noqa: BLE001 - the broadcast never breaks the event
|
|
758
765
|
module, presence = None, None
|
|
759
|
-
|
|
760
|
-
|
|
766
|
+
if probe:
|
|
767
|
+
# Everything a real event would compute, nothing it would write: no event appended, no session
|
|
768
|
+
# snapshot, no presence. Validating that a CLI's hook is wired must never invent a live session.
|
|
769
|
+
stamped = dict(event, mode=current["mode"])
|
|
770
|
+
snap = {"key": session_key(host, event.get("session"), event.get("cwd")), "host": host,
|
|
771
|
+
"session": event.get("session"), "cwd": event.get("cwd") or process_cwd(),
|
|
772
|
+
"state": "working", "presence": {}}
|
|
773
|
+
else:
|
|
774
|
+
stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(),
|
|
775
|
+
presence=presence)
|
|
776
|
+
if raw in STOP_EVENTS and not probe:
|
|
761
777
|
cli = record_processes(home, host, event)
|
|
762
778
|
if cli and snap is not None:
|
|
763
779
|
bmod = broadcast_module()
|
|
@@ -786,12 +802,12 @@ def handle(host, raw, payload, home, env=None, check=False):
|
|
|
786
802
|
stamp = current["mode"] + "@" + str(current["updatedAt"])
|
|
787
803
|
if host in ("codex", "gemini", "devin") and (raw == "SessionStart" or (seen and seen != stamp)):
|
|
788
804
|
system = indicator(current["mode"]) # visible line only at start or when the mode changed
|
|
789
|
-
if snap is not None and seen != stamp:
|
|
805
|
+
if snap is not None and seen != stamp and not probe:
|
|
790
806
|
update_session(home, dict(stamped, event="other"), {"modeSeen": stamp, "mode": current["mode"]})
|
|
791
807
|
if raw == "SessionStart":
|
|
792
808
|
return output_for(host, raw, system=system) if system else None
|
|
793
809
|
context = context_line(current["mode"])
|
|
794
|
-
memory = resource_hint(home)
|
|
810
|
+
memory = resource_hint(home, write_through=not probe)
|
|
795
811
|
if memory:
|
|
796
812
|
context += "\n" + memory
|
|
797
813
|
if module is not None and snap is not None:
|
|
@@ -854,8 +870,8 @@ def fast_hook_args(argv):
|
|
|
854
870
|
if name in ("--host", "--event", "--home") and i + 1 < len(argv):
|
|
855
871
|
values[name[2:]] = argv[i + 1]
|
|
856
872
|
i += 2
|
|
857
|
-
elif name
|
|
858
|
-
flags.add(
|
|
873
|
+
elif name in ("--check", "--probe"):
|
|
874
|
+
flags.add(name[2:])
|
|
859
875
|
i += 1
|
|
860
876
|
else:
|
|
861
877
|
return None
|
|
@@ -863,7 +879,7 @@ def fast_hook_args(argv):
|
|
|
863
879
|
return None
|
|
864
880
|
import types
|
|
865
881
|
return types.SimpleNamespace(command="hook", host=values["host"], event=values["event"], home=values.get("home"),
|
|
866
|
-
check="check" in flags)
|
|
882
|
+
check="check" in flags, probe="probe" in flags)
|
|
867
883
|
|
|
868
884
|
|
|
869
885
|
def main(argv=None):
|
|
@@ -882,6 +898,8 @@ def parse_args(argv):
|
|
|
882
898
|
hook.add_argument("--event", required=True)
|
|
883
899
|
hook.add_argument("--home")
|
|
884
900
|
hook.add_argument("--check", action="store_true")
|
|
901
|
+
# Exercise the hook without side effects: validating a CLI's wiring must not register a session.
|
|
902
|
+
hook.add_argument("--probe", action="store_true")
|
|
885
903
|
mode = sub.add_parser("mode")
|
|
886
904
|
mode.add_argument("action", nargs="?", default="status", choices=("on", "off", "status", "toggle"))
|
|
887
905
|
mode.add_argument("--home")
|
|
@@ -908,11 +926,11 @@ def run_main(args):
|
|
|
908
926
|
try:
|
|
909
927
|
raw = sys.stdin.buffer.read(MAX_STDIN + 1) if not sys.stdin.isatty() else b""
|
|
910
928
|
payload = json.loads(raw.decode("utf-8")) if raw.strip() and len(raw) <= MAX_STDIN else {}
|
|
911
|
-
result = handle(args.host, args.event, payload, home, check=args.check)
|
|
929
|
+
result = handle(args.host, args.event, payload, home, check=args.check, probe=getattr(args, "probe", False))
|
|
912
930
|
if result:
|
|
913
931
|
sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
914
932
|
sys.stdout.flush()
|
|
915
|
-
if args.event == "SessionStart" and not args.check:
|
|
933
|
+
if args.event == "SessionStart" and not args.check and not getattr(args, "probe", False):
|
|
916
934
|
prune_sessions(home)
|
|
917
935
|
module = broadcast_module()
|
|
918
936
|
if module is not None:
|
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)
|