@andespindola/crow-skills 0.11.0 → 0.12.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/autoupdate.py +77 -4
- package/crow_skills/broadcast.py +242 -2
- package/crow_skills/cli.py +121 -4
- package/crow_skills/guide.py +29 -7
- package/crow_skills/hook.py +60 -3
- package/crow_skills/machine.py +79 -4
- package/crow_skills/projection.py +128 -0
- package/crow_skills/resources.py +876 -25
- package/docs/cli.md +56 -3
- package/package.json +1 -1
- package/release.json +28 -18
- package/skills/crow-behaviour/SKILL.md +2 -1
- package/skills/crow-behaviour/references/broadcast.md +2 -0
- package/skills/crow-behaviour/references/processes.md +42 -0
- package/skills/crow-behaviour/references/progress-ledger.md +4 -0
- package/skills/crow-behaviour/references/resources.md +1 -1
- package/skills/crow-behaviour/scripts/crow_ledger.py +619 -3
package/crow_skills/guide.py
CHANGED
|
@@ -187,17 +187,39 @@ def managed_skills(agent, home, store):
|
|
|
187
187
|
target = destinations(agent, home)[0]
|
|
188
188
|
rows = store.status([target], ())
|
|
189
189
|
store_ok = store_managed(home, store) if any(r["status"] == "uniflow_link" for r in rows) else set()
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
names = {r["name"] for r in rows if r["status"] in ("installed", "modified")
|
|
191
|
+
or (r["status"] == "uniflow_link" and r["name"] in store_ok)}
|
|
192
|
+
from .projection import projected
|
|
193
|
+
return sorted(names | projected(agent, home, store)) # codex: Uniflow's links in ~/.codex/skills
|
|
192
194
|
|
|
193
195
|
|
|
194
|
-
def
|
|
195
|
-
"""
|
|
196
|
-
A link to a store entry the user wrote by hand is not a crow-skills install for the CLI."""
|
|
196
|
+
def store_statuses(home, store):
|
|
197
|
+
"""{name: status} of the Uniflow store (crow-skills view); {} when it cannot be read."""
|
|
197
198
|
try:
|
|
198
|
-
return {r["name"] for r in store.status(destinations("uniflow", home), ()) if r["
|
|
199
|
+
return {r["name"]: r["status"] for r in store.status(destinations("uniflow", home), ()) if r["name"]}
|
|
199
200
|
except (CrowError, OSError):
|
|
200
|
-
return
|
|
201
|
+
return {}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def store_managed(home, store):
|
|
205
|
+
"""Names whose Uniflow store copy is an intact crow-skills install. A link to a store entry the user wrote
|
|
206
|
+
by hand, or to a managed entry that was edited or lost files (a CLI may fail to load it), does not count
|
|
207
|
+
as an install for the CLI."""
|
|
208
|
+
return {n for n, s in store_statuses(home, store).items() if s == "installed"}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def store_broken(home, store):
|
|
212
|
+
"""Managed store entries that were edited or lost files (status modified/missing)."""
|
|
213
|
+
return {n for n, s in store_statuses(home, store).items() if s in ("modified", "missing")}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
BROKEN_REASON = ("a entrada do store do Uniflow foi editada ou perdeu arquivos; o CLI pode não carregá-la, então não conta "
|
|
217
|
+
"como instalada")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def broken_fix(name):
|
|
221
|
+
return ("revise o store e, para restaurá-lo com backup: crow-skills update " + name +
|
|
222
|
+
" --agents uniflow --global --overwrite-modified --yes")
|
|
201
223
|
|
|
202
224
|
|
|
203
225
|
def present_skills(agent, home, store, catalog):
|
package/crow_skills/hook.py
CHANGED
|
@@ -27,6 +27,12 @@ live sessions in the same repository, only when there are any; ``--check``
|
|
|
27
27
|
live session on the same file or clone without writing anything.
|
|
28
28
|
|
|
29
29
|
crow_hook.py hook --host HOST --event PreToolUse --check [--home DIR]
|
|
30
|
+
|
|
31
|
+
On Stop and SessionEnd the hook also records the live descendants of the
|
|
32
|
+
session's CLI (pid, start time, executable plus package/script name; never
|
|
33
|
+
arguments) in the crow-broadcast state, and at most every 10 minutes refreshes
|
|
34
|
+
the orphan count (crow-skills resources --orphans) that the memory line cites
|
|
35
|
+
under pressure: one ps call on those events, none on prompts.
|
|
30
36
|
"""
|
|
31
37
|
from datetime import datetime, timezone
|
|
32
38
|
import hashlib
|
|
@@ -236,11 +242,48 @@ def resource_hint(home):
|
|
|
236
242
|
if module is None:
|
|
237
243
|
return None
|
|
238
244
|
try:
|
|
239
|
-
|
|
245
|
+
text = module.hint(module.probe(home, processes=False, use_cache=True))
|
|
246
|
+
if text and hasattr(module, "orphan_hint"):
|
|
247
|
+
text += module.orphan_hint(home) # last orphan scan, a file read: no ps call per prompt
|
|
248
|
+
return text
|
|
240
249
|
except Exception: # noqa: BLE001
|
|
241
250
|
return None
|
|
242
251
|
|
|
243
252
|
|
|
253
|
+
STOP_EVENTS = ("Stop", "AfterAgent", "PostInvocation", "SessionEnd")
|
|
254
|
+
ORPHAN_REFRESH = 600 # seconds: a Stop event rescans orphans at most this often (it already has the table)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def record_processes(home, host, event, ppid=None):
|
|
258
|
+
"""Stop/SessionEnd: record the CLI's live descendants in crow-broadcast state (pid, start,
|
|
259
|
+
redacted command), so a later orphan check can attribute leftovers to a dead session; and
|
|
260
|
+
refresh the orphan count the pressure hint reads. One ps call; never raises."""
|
|
261
|
+
module, probe = broadcast_module(), resources_module()
|
|
262
|
+
if module is None or probe is None or not hasattr(probe, "read_table") or not hasattr(module, "record_processes"):
|
|
263
|
+
return None
|
|
264
|
+
try:
|
|
265
|
+
cfg = module.config(home)
|
|
266
|
+
if not cfg["enabled"]:
|
|
267
|
+
return None
|
|
268
|
+
rows = probe.read_table()
|
|
269
|
+
if not rows:
|
|
270
|
+
return None
|
|
271
|
+
now = time.time()
|
|
272
|
+
cli = probe.hook_cli_pid(rows, os.getppid() if ppid is None else ppid)
|
|
273
|
+
row = next((r for r in rows if r["pid"] == cli), None)
|
|
274
|
+
if row is None or cli <= 1:
|
|
275
|
+
return None
|
|
276
|
+
key = session_key(host, event.get("session"), event.get("cwd"))
|
|
277
|
+
module.record_processes(home, key, host, cli, probe.start_of(row, now),
|
|
278
|
+
probe.session_descendants(rows, cli, now=now), now)
|
|
279
|
+
if probe.read_orphan_cache(home, max_age=ORPHAN_REFRESH) is None:
|
|
280
|
+
doc = probe.orphan_scan(home, rows=rows, context=module.process_context(home, cfg, now, event.get("cwd")), now=now)
|
|
281
|
+
probe.write_orphan_cache(home, doc)
|
|
282
|
+
return cli
|
|
283
|
+
except Exception: # noqa: BLE001 - housekeeping never breaks the event
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
244
287
|
# --- event bus ----------------------------------------------------------------------------
|
|
245
288
|
|
|
246
289
|
def append_event(home, event):
|
|
@@ -373,9 +416,12 @@ def update_session(home, event, extra=None, fallback_cwd=None, presence=None):
|
|
|
373
416
|
if extra:
|
|
374
417
|
snap.update(extra)
|
|
375
418
|
if presence:
|
|
376
|
-
module, touched, ppid = presence
|
|
419
|
+
module, touched, ppid = presence[:3]
|
|
377
420
|
try:
|
|
378
|
-
|
|
421
|
+
if len(presence) > 3: # (…, cli pid resolved from the process table on Stop/SessionEnd)
|
|
422
|
+
module.fold(snap, event, touched, ppid=ppid, cli_pid=presence[3])
|
|
423
|
+
else:
|
|
424
|
+
module.fold(snap, event, touched, ppid=ppid)
|
|
379
425
|
except Exception: # noqa: BLE001 - presence is best effort
|
|
380
426
|
pass
|
|
381
427
|
write_json(path, snap)
|
|
@@ -696,6 +742,15 @@ def handle(host, raw, payload, home, env=None, check=False):
|
|
|
696
742
|
except Exception: # noqa: BLE001 - the broadcast never breaks the event
|
|
697
743
|
module, presence = None, None
|
|
698
744
|
stamped, snap = record(home, dict(event, mode=current["mode"]), snapshot=True, fallback_cwd=process_cwd(), presence=presence)
|
|
745
|
+
if raw in STOP_EVENTS:
|
|
746
|
+
cli = record_processes(home, host, event)
|
|
747
|
+
if cli and snap is not None:
|
|
748
|
+
bmod = broadcast_module()
|
|
749
|
+
if bmod is not None:
|
|
750
|
+
try: # the resolved CLI pid becomes the presence pid (a hook's sh -c parent is transient)
|
|
751
|
+
update_session(home, dict(stamped, event="other"), presence=(bmod, [], None, cli))
|
|
752
|
+
except Exception: # noqa: BLE001
|
|
753
|
+
pass
|
|
699
754
|
if module is not None and host in SYNC_TOOL_HOSTS:
|
|
700
755
|
if raw in TOOL_START_EVENTS and (touched or tree or checked):
|
|
701
756
|
return broadcast_check(host, raw, home, module, snap, checked or touched, tree)
|
|
@@ -848,6 +903,8 @@ def run_main(args):
|
|
|
848
903
|
if module is not None:
|
|
849
904
|
try:
|
|
850
905
|
module.prune_claims(home)
|
|
906
|
+
if hasattr(module, "prune_processes"):
|
|
907
|
+
module.prune_processes(home)
|
|
851
908
|
except Exception: # noqa: BLE001 - pruning is housekeeping; the output already went out
|
|
852
909
|
pass
|
|
853
910
|
except Exception: # noqa: BLE001 - a hook must never break the agent
|
package/crow_skills/machine.py
CHANGED
|
@@ -173,6 +173,7 @@ def classify(store, target, names, agent, replace, overwrite, store_names=()):
|
|
|
173
173
|
pending = set(store_names) if projected else set()
|
|
174
174
|
store_ok = guide.store_managed(store.home, store) if projected else set()
|
|
175
175
|
disabled = store_disabled(store.home, store) if projected else set()
|
|
176
|
+
broken = guide.store_broken(store.home, store) if projected else set()
|
|
176
177
|
for name in names:
|
|
177
178
|
row = rows.get(name) or {"status": "absent"}
|
|
178
179
|
status = row["status"]
|
|
@@ -181,6 +182,9 @@ def classify(store, target, names, agent, replace, overwrite, store_names=()):
|
|
|
181
182
|
elif status == "uniflow_link" and name in disabled:
|
|
182
183
|
kept.append({"name": name, "status": "uniflow_link_disabled", "reason": DISABLED_REASON,
|
|
183
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)})
|
|
184
188
|
elif status == "uniflow_link":
|
|
185
189
|
kept.append({"name": name, "status": "uniflow_link_unmanaged",
|
|
186
190
|
"reason": "link do Uniflow para um pacote do store que o crow-skills não gerencia; não conta como instalada",
|
|
@@ -240,6 +244,8 @@ def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
|
|
|
240
244
|
try:
|
|
241
245
|
install, entry["kept"], entry["via_uniflow"], waiting = classify(store, target, names, agent, replace, overwrite,
|
|
242
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)
|
|
243
249
|
if waiting:
|
|
244
250
|
entry["via_uniflow_pending"] = waiting
|
|
245
251
|
if not install:
|
|
@@ -272,6 +278,60 @@ def configure_agent(args, home, store, agent, names, dry_run, store_names=()):
|
|
|
272
278
|
return entry
|
|
273
279
|
|
|
274
280
|
|
|
281
|
+
DUPLICATE = "removida: duplicava a projeção do Uniflow"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def codex_projection_step(args, home, store, entry, names, install, waiting, store_names, dry_run):
|
|
285
|
+
"""Codex sees ~/.codex/skills (Uniflow's links to its store) and ~/.agents/skills (our copies) and lists a
|
|
286
|
+
same-name skill twice. A name Codex sees via a managed projection gets no native copy, and an intact
|
|
287
|
+
managed native copy of it is removed through uninstall (backup, transaction, rollback). Returns the
|
|
288
|
+
names still to install natively."""
|
|
289
|
+
from . import projection
|
|
290
|
+
for name, kind in sorted(projection.links(home, store).items()):
|
|
291
|
+
if kind == "modified" and name in names:
|
|
292
|
+
entry["kept"].append({"name": name, "status": "uniflow_link_modified",
|
|
293
|
+
"reason": guide.BROKEN_REASON + "; a cópia nativa em ~/.agents/skills continua valendo",
|
|
294
|
+
"fix": guide.broken_fix(name)})
|
|
295
|
+
projected = projection.projected("codex", home, store)
|
|
296
|
+
if not projected:
|
|
297
|
+
return install
|
|
298
|
+
selected = set(names)
|
|
299
|
+
# Names on their way into the store reach Codex by the same projection on its next start.
|
|
300
|
+
pending = (set(store_names) - projected - {"crow-behaviour"}) & set(install)
|
|
301
|
+
pending = {n for n in pending if n not in entry["unchanged"]}
|
|
302
|
+
native = {r["name"]: r["status"] for r in store.status([destinations("codex", home)[0]], sorted(pending)) if r["name"]}
|
|
303
|
+
pending = {n for n in pending if native.get(n, "absent") == "absent"}
|
|
304
|
+
entry["via_uniflow"] = sorted(set(entry["via_uniflow"]) | (projected & selected) | pending)
|
|
305
|
+
waiting.extend(sorted(pending))
|
|
306
|
+
dupes = projection.duplicates(home, store)
|
|
307
|
+
overwrite = bool(getattr(args, "overwrite_modified", False))
|
|
308
|
+
remove = [n for n in dupes["remove"] + (dupes["modified"] if overwrite else []) if n in selected]
|
|
309
|
+
entry["kept"] = [k for k in entry["kept"] if k["name"] not in projected]
|
|
310
|
+
for name in dupes["modified"]:
|
|
311
|
+
if name in selected and not overwrite:
|
|
312
|
+
entry["kept"].append({"name": name, "status": "duplicate_modified",
|
|
313
|
+
"reason": "cópia nativa editada que duplica a projeção do Uniflow no Codex",
|
|
314
|
+
"fix": "revise e, para remover com backup: crow-skills setup --agents codex --overwrite-modified --yes"})
|
|
315
|
+
for name in dupes["shared"]:
|
|
316
|
+
if name in selected:
|
|
317
|
+
entry["kept"].append({"name": name, "status": "duplicate_shared",
|
|
318
|
+
"reason": "cópia nativa também registrada para shared (diretório lido por outros hosts); "
|
|
319
|
+
"mantida, e o Codex a vê duas vezes",
|
|
320
|
+
"fix": "se nenhum outro host precisa dela: crow-skills uninstall " + name +
|
|
321
|
+
" --agents codex,shared --global --yes"})
|
|
322
|
+
if remove:
|
|
323
|
+
cli = _cli()
|
|
324
|
+
argv = ["uninstall", *remove, "--agents", "codex", "--global", "--json", "--home", str(home),
|
|
325
|
+
"--dry-run" if dry_run else "--yes"]
|
|
326
|
+
if overwrite:
|
|
327
|
+
argv.append("--overwrite-modified")
|
|
328
|
+
argv.append("--no-integrations") # hooks/trust follow in the install/refresh step, with the run's --no-trust
|
|
329
|
+
data = cli.run(cli.parser().parse_args(argv))
|
|
330
|
+
entry["removed"] = [{"name": c["name"], "reason": DUPLICATE} for c in data["changes"] if c["action"] != "unchanged"]
|
|
331
|
+
entry["removal_transaction"] = data.get("transaction")
|
|
332
|
+
return [n for n in install if n not in projected and n not in pending]
|
|
333
|
+
|
|
334
|
+
|
|
275
335
|
def machine_steps(args, home, store, dry_run):
|
|
276
336
|
steps = {}
|
|
277
337
|
# Crow mode: written as on only when the user never chose (no mode file).
|
|
@@ -409,6 +469,8 @@ def summarize(detected, entries, store, home, probe, dry_run, trust=True):
|
|
|
409
469
|
parts.append(str(unchanged) + " já em dia")
|
|
410
470
|
if entry.get("via_uniflow"):
|
|
411
471
|
parts.append(str(len(entry["via_uniflow"])) + " via uniflow")
|
|
472
|
+
if entry.get("removed"):
|
|
473
|
+
parts.append(str(len(entry["removed"])) + (" a remover" if dry_run else " removidas") + " (duplicavam o Uniflow)")
|
|
412
474
|
if kept:
|
|
413
475
|
parts.append(str(len(kept)) + " mantidas")
|
|
414
476
|
item["skills"] = ", ".join(parts) or "—"
|
|
@@ -466,7 +528,7 @@ def summarize(detected, entries, store, home, probe, dry_run, trust=True):
|
|
|
466
528
|
elif kept or state in ("warn", "absent"):
|
|
467
529
|
item["state"] = "warn"
|
|
468
530
|
else:
|
|
469
|
-
item["state"] = "plan" if dry_run and (entry["changed"] or real_changes(entry.get("integrations"))) else "ok"
|
|
531
|
+
item["state"] = "plan" if dry_run and (entry["changed"] or entry.get("removed") or real_changes(entry.get("integrations"))) else "ok"
|
|
470
532
|
summary.append(item)
|
|
471
533
|
return summary
|
|
472
534
|
|
|
@@ -497,7 +559,7 @@ def setup(args, home, store, interactive):
|
|
|
497
559
|
entries, steps = build(True)
|
|
498
560
|
result = {"version": __version__, "revision": source.revision, "skills": names, "detected": detected, "agents": agents,
|
|
499
561
|
"absent": [r["id"] for r in detected if not r["detected"]], "trust": not getattr(args, "no_trust", False)}
|
|
500
|
-
pending = any(e["changed"] or real_changes(e.get("integrations")) or real_changes(e.get("guides"))
|
|
562
|
+
pending = any(e["changed"] or e.get("removed") or real_changes(e.get("integrations")) or real_changes(e.get("guides"))
|
|
501
563
|
for e in entries) or any(s["state"] == "plan" for s in steps.values())
|
|
502
564
|
if dry_run or not agents:
|
|
503
565
|
summary = summarize(detected, entries, store, home, False, True)
|
|
@@ -564,11 +626,21 @@ def upgrade_summary(skills, dry_run):
|
|
|
564
626
|
agent = s["agent"]
|
|
565
627
|
item = {"agent": agent, "label": AGENT_LABELS.get(agent, agent), "changed": s.get("updated") or [],
|
|
566
628
|
"unchanged": s.get("unchanged", 0), "kept": s.get("kept") or [], "via_uniflow": len(s.get("via_uniflow") or []),
|
|
629
|
+
"removed": [r["name"] for r in s.get("removed") or []],
|
|
567
630
|
"needs_user": []}
|
|
568
631
|
for k in item["kept"]:
|
|
569
632
|
if k["status"] in ("modified", "missing"):
|
|
570
633
|
item["needs_user"].append(k["name"] + " editada localmente; para substituir com backup: crow-skills update " +
|
|
571
634
|
k["name"] + " --agents " + agent + " --global --overwrite-modified --yes")
|
|
635
|
+
elif k["status"] == "duplicate_modified":
|
|
636
|
+
item["needs_user"].append(k["name"] + " (cópia nativa editada) duplica a projeção do Uniflow no Codex; para remover "
|
|
637
|
+
"com backup: crow-skills setup --agents codex --overwrite-modified --yes")
|
|
638
|
+
elif k["status"] == "uniflow_link_modified":
|
|
639
|
+
item["needs_user"].append(k["name"] + ": a entrada do store do Uniflow foi editada ou perdeu arquivos; para "
|
|
640
|
+
"restaurá-la com backup: " + k.get("fix", "crow-skills update " + k["name"] +
|
|
641
|
+
" --agents uniflow --global --overwrite-modified --yes").split(": ", 1)[-1])
|
|
642
|
+
elif k["status"] == "duplicate_shared":
|
|
643
|
+
item["needs_user"].append(k["name"] + " duplica a projeção do Uniflow no Codex, mas também é de shared; mantida")
|
|
572
644
|
elif k["status"] == "other_origin":
|
|
573
645
|
item["needs_user"].append(k["name"] + " instalada de outra origem; para trocar: crow-skills install " +
|
|
574
646
|
k["name"] + " --agents " + agent + " --global --yes")
|
|
@@ -576,7 +648,7 @@ def upgrade_summary(skills, dry_run):
|
|
|
576
648
|
item["needs_user"].append(k["name"] + ": " + k["status"])
|
|
577
649
|
if s.get("error"):
|
|
578
650
|
item["needs_user"].insert(0, s["error"]["message"])
|
|
579
|
-
item["state"] = "fail" if s.get("error") else ("warn" if item["needs_user"] else ("plan" if dry_run and item["changed"] else "ok"))
|
|
651
|
+
item["state"] = "fail" if s.get("error") else ("warn" if item["needs_user"] else ("plan" if dry_run and (item["changed"] or item["removed"]) else "ok"))
|
|
580
652
|
rows.append(item)
|
|
581
653
|
return rows
|
|
582
654
|
|
|
@@ -698,8 +770,11 @@ def render_upgrade(data, stream=None):
|
|
|
698
770
|
rows = []
|
|
699
771
|
for item in data.get("summary", []):
|
|
700
772
|
changed = ", ".join(item["changed"]) if len(item["changed"]) <= 3 else str(len(item["changed"])) + " skills"
|
|
773
|
+
removed = ((" · " if changed else "") + str(len(item["removed"])) + (" a remover" if dry else " removidas") +
|
|
774
|
+
" (duplicavam o Uniflow)") if item.get("removed") else ""
|
|
701
775
|
rows.append([ui.mark(STATE_MARK[item["state"]], stream), item["label"],
|
|
702
|
-
(("a atualizar: " if dry else "atualizadas: ") + changed) if changed else "sem mudanças"
|
|
776
|
+
((("a atualizar: " if dry else "atualizadas: ") + changed) if changed else ("" if removed else "sem mudanças"))
|
|
777
|
+
+ removed,
|
|
703
778
|
str(item["unchanged"]), str(item.get("via_uniflow", 0)), str(len(item["kept"]))])
|
|
704
779
|
if rows:
|
|
705
780
|
lines.append("")
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Uniflow's projection of its store into Codex's own skills dir ($CODEX_HOME/skills, default ~/.codex/skills).
|
|
2
|
+
|
|
3
|
+
Codex scans both $CODEX_HOME/skills and ~/.agents/skills (where crow-skills installs for codex), follows
|
|
4
|
+
symlinks and lists a same-name skill twice when the two entries resolve to different directories. Uniflow
|
|
5
|
+
links ~/.codex/skills/crow-x -> <store>/crow-x, so a native copy in ~/.agents/skills duplicates it.
|
|
6
|
+
|
|
7
|
+
A link counts as the codex install of a skill ("via uniflow") only when it resolves to <store>/<name> and
|
|
8
|
+
that store entry is crow-skills-managed (guide.store_managed); a store entry Uniflow disabled is reported as
|
|
9
|
+
disabled; a link to anything else is ignored. A link ~/.codex/skills/crow-x -> ~/.agents/skills/crow-x is an
|
|
10
|
+
alias Codex deduplicates by canonical path: harmless, never touched.
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .agents import destinations, real_home, uniflow_store
|
|
16
|
+
from .source import CrowError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def codex_home(home):
|
|
20
|
+
"""$CODEX_HOME for the real profile (as Codex resolves it); an isolated --home uses <home>/.codex."""
|
|
21
|
+
home = Path(home)
|
|
22
|
+
if real_home(home) and os.environ.get("CODEX_HOME"):
|
|
23
|
+
return Path(os.path.abspath(Path(os.environ["CODEX_HOME"]).expanduser()))
|
|
24
|
+
return Path(os.path.abspath(home / ".codex"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def codex_skills_dir(home):
|
|
28
|
+
return codex_home(home) / "skills"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def links(home, store):
|
|
32
|
+
"""{name: "managed" | "disabled" | "modified" | "unmanaged" | "alias" | "other"} for crow-* symlinks in Codex's own
|
|
33
|
+
skills dir: to an intact managed / Uniflow-disabled / edited-or-incomplete managed / hand-written store entry, to our
|
|
34
|
+
own copy, or elsewhere. Only "managed" counts as the codex install and allows removing a native duplicate."""
|
|
35
|
+
directory = codex_skills_dir(home)
|
|
36
|
+
try:
|
|
37
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
38
|
+
return {}
|
|
39
|
+
entries = [p for p in directory.iterdir() if p.name.startswith("crow-") and p.is_symlink()]
|
|
40
|
+
except OSError:
|
|
41
|
+
return {}
|
|
42
|
+
if not entries:
|
|
43
|
+
return {}
|
|
44
|
+
store_dir = uniflow_store(home)
|
|
45
|
+
native = Path(destinations("codex", home)[0]["path"])
|
|
46
|
+
from .guide import store_statuses
|
|
47
|
+
statuses = store_statuses(home, store) # only an intact ("installed") store entry is a working codex install
|
|
48
|
+
result = {}
|
|
49
|
+
for link in entries:
|
|
50
|
+
name = link.name
|
|
51
|
+
resolved = os.path.realpath(link)
|
|
52
|
+
if resolved == os.path.realpath(native / name):
|
|
53
|
+
result[name] = "alias"
|
|
54
|
+
elif (resolved == os.path.realpath(store_dir / name) and (store_dir / name).is_dir()
|
|
55
|
+
and not (store_dir / name).is_symlink()):
|
|
56
|
+
state = statuses.get(name)
|
|
57
|
+
result[name] = {"installed": "managed", "disabled": "disabled", "modified": "modified",
|
|
58
|
+
"missing": "modified"}.get(state, "unmanaged")
|
|
59
|
+
else:
|
|
60
|
+
result[name] = "other"
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def status_rows(home, store, rows):
|
|
65
|
+
"""Rows for `status`/`doctor`: Codex's view through Uniflow's links, as a destination of its own."""
|
|
66
|
+
directory = str(codex_skills_dir(home))
|
|
67
|
+
extra = []
|
|
68
|
+
for name, kind in sorted(links(home, store).items()):
|
|
69
|
+
if kind == "managed":
|
|
70
|
+
extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "uniflow_link", "revision": None,
|
|
71
|
+
"activation_verified": False, "projection": "codex_home",
|
|
72
|
+
"note": "Link do Uniflow em " + directory + " para o store; é a instalação do codex (via uniflow)."})
|
|
73
|
+
elif kind == "disabled":
|
|
74
|
+
extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "disabled", "revision": None,
|
|
75
|
+
"activation_verified": False, "projection": "uniflow_link_disabled",
|
|
76
|
+
"note": "Desabilitada no Uniflow (SKILL.md.disabled no store); reabilite no Uniflow para usar."})
|
|
77
|
+
elif kind == "modified":
|
|
78
|
+
from .guide import BROKEN_REASON, broken_fix
|
|
79
|
+
extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "modified", "revision": None,
|
|
80
|
+
"activation_verified": False, "projection": "uniflow_link_modified",
|
|
81
|
+
"note": BROKEN_REASON[0].upper() + BROKEN_REASON[1:] + ". Para corrigir: " + broken_fix(name)})
|
|
82
|
+
elif kind == "unmanaged":
|
|
83
|
+
extra.append({"name": name, "target": directory, "agents": ["codex"], "status": "unmanaged", "revision": None,
|
|
84
|
+
"activation_verified": False, "projection": "uniflow_link_unmanaged",
|
|
85
|
+
"note": "Link do Uniflow para um pacote do store que o crow-skills não gerencia; não conta como instalada. "
|
|
86
|
+
"Revise o store e, para gerenciá-lo com backup: crow-skills setup --agents uniflow --replace --yes"})
|
|
87
|
+
return extra
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def projected(agent, home, store):
|
|
91
|
+
"""Names Codex already sees through a managed Uniflow projection (empty for other agents)."""
|
|
92
|
+
if agent != "codex":
|
|
93
|
+
return set()
|
|
94
|
+
return {n for n, kind in links(home, store).items() if kind == "managed"}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def native_target(agent, target, home):
|
|
98
|
+
"""target is the user-scope codex install dir (not --project/--dest)."""
|
|
99
|
+
return agent == "codex" and os.path.abspath(target["path"]) == os.path.abspath(destinations("codex", home)[0]["path"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def duplicates(home, store):
|
|
103
|
+
"""Native copies in ~/.agents/skills that duplicate a managed projection, by what can be done with them.
|
|
104
|
+
|
|
105
|
+
Returns {"remove": [...], "modified": [...], "shared": [...]}: "remove" are crow-skills-managed and intact;
|
|
106
|
+
"modified" were edited locally (kept unless --overwrite-modified); "shared" are also recorded for another
|
|
107
|
+
agent of that dir (shared), which may rely on them."""
|
|
108
|
+
names = projected("codex", home, store)
|
|
109
|
+
result = {"remove": [], "modified": [], "shared": []}
|
|
110
|
+
if not names:
|
|
111
|
+
return result
|
|
112
|
+
target = destinations("codex", home)[0]
|
|
113
|
+
try:
|
|
114
|
+
rows = {r["name"]: r for r in store.status([target], sorted(names)) if r["name"]}
|
|
115
|
+
records = store.state(target["path"])["installed"]
|
|
116
|
+
except (CrowError, OSError):
|
|
117
|
+
return result
|
|
118
|
+
for name in sorted(names):
|
|
119
|
+
row = rows.get(name)
|
|
120
|
+
if not row or row["status"] not in ("installed", "modified", "missing"):
|
|
121
|
+
continue # absent, unmanaged or unreadable: nothing of ours to remove
|
|
122
|
+
if set(records.get(name, {}).get("agents") or ["codex"]) - {"codex"}:
|
|
123
|
+
result["shared"].append(name)
|
|
124
|
+
elif row["status"] == "installed":
|
|
125
|
+
result["remove"].append(name)
|
|
126
|
+
else:
|
|
127
|
+
result["modified"].append(name)
|
|
128
|
+
return result
|