@andespindola/crow-skills 0.7.1 → 0.8.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 +6 -5
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/autoupdate.py +398 -0
- package/crow_skills/cli.py +89 -3
- package/crow_skills/events.py +111 -0
- package/crow_skills/guide.py +7 -1
- package/crow_skills/hook.py +560 -0
- package/crow_skills/integrations.py +427 -63
- package/crow_skills/progress.py +106 -14
- package/crow_skills/source.py +40 -0
- package/crow_skills/storage.py +5 -4
- package/docs/cli.md +96 -17
- package/package.json +1 -1
- package/release.json +30 -15
- package/skills/crow-behaviour/SKILL.md +3 -3
- package/skills/crow-behaviour/references/progress-ledger.md +17 -3
- package/skills/crow-behaviour/scripts/crow_ledger.py +45 -2
|
@@ -12,10 +12,19 @@ checked on the host versions listed in docs/cli.md:
|
|
|
12
12
|
when the user has not set a status line.
|
|
13
13
|
- tmux-status: a segment appended to ``status-right`` from a sourced snippet,
|
|
14
14
|
for every agent, only when tmux is installed and already configured.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
- agy-statusline: ``statusLine`` in ~/.gemini/antigravity-cli/settings.json,
|
|
16
|
+
stacked under Antigravity's default line (``stack_with_default``).
|
|
17
|
+
- *-hooks: the crow mode hook (crow_hook.py) in each CLI's own hook config:
|
|
18
|
+
claude (~/.claude/settings.json), codex (~/.codex/hooks.json), Gemini CLI
|
|
19
|
+
(~/.gemini/settings.json), agy (~/.gemini/config/hooks.json) and Devin
|
|
20
|
+
(~/.config/devin/config.json). Entries are appended after the user's own and
|
|
21
|
+
identified by the script path; nothing else in those files changes.
|
|
22
|
+
- claude-command / gemini-command: a ``/crow on|off|status`` slash command in
|
|
23
|
+
a file of its own (~/.claude/commands/crow.md, ~/.gemini/commands/crow.toml).
|
|
24
|
+
|
|
25
|
+
Gemini CLI (footer takes built-in items only), Codex (built-in items only)
|
|
26
|
+
and Devin CLI (no status line) show the mode as a one-line systemMessage
|
|
27
|
+
from the hook when it changes; they also get the tmux segment.
|
|
19
28
|
|
|
20
29
|
Ownership: one key (claude), one marked block (codex, tmux.conf) and two whole
|
|
21
30
|
files under ~/.config/crow-skills and the CLI state. Every edited file is
|
|
@@ -39,19 +48,27 @@ from .guide import protect, read_regular, sha, tomllib, write_atomic
|
|
|
39
48
|
from .source import CrowError
|
|
40
49
|
from .storage import atomic_json, checked, private_dir, read_json
|
|
41
50
|
|
|
42
|
-
NAMES = ("claude-statusline", "codex-statusline", "
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
NAMES = ("claude-statusline", "claude-hooks", "claude-command", "codex-statusline", "codex-hooks",
|
|
52
|
+
"gemini-hooks", "gemini-command", "agy-hooks", "agy-statusline", "devin-hooks", "tmux-status")
|
|
53
|
+
OWNERS = {"claude-statusline": ("claude",), "claude-hooks": ("claude",), "claude-command": ("claude",),
|
|
54
|
+
"codex-statusline": ("codex",), "codex-hooks": ("codex",),
|
|
55
|
+
"gemini-hooks": ("gemini",), "gemini-command": ("gemini",), "agy-hooks": ("gemini",), "agy-statusline": ("gemini",),
|
|
56
|
+
"devin-hooks": ("devin",), "tmux-status": ("claude", "codex", "gemini", "devin")}
|
|
57
|
+
HOOK_SCRIPT = "crow_hook.py"
|
|
58
|
+
COMMAND_HEADER = "Gerenciado por crow-skills (comando /crow). Não edite; use crow-skills integrations."
|
|
45
59
|
BEGIN = "# >>> crow-skills integrations (gerenciado; use crow-skills integrations uninstall) >>>"
|
|
46
60
|
END = "# <<< crow-skills integrations <<<"
|
|
47
61
|
CODEX_ITEMS = ["model-with-reasoning", "context-remaining", "current-dir", "task-progress", "used-tokens"]
|
|
48
62
|
SNIPPET_HEADER = "# Gerenciado por crow-skills (integração tmux). Não edite; use crow-skills integrations."
|
|
49
63
|
UNSUPPORTED = {
|
|
50
|
-
"gemini": ("
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
"gemini": ("Gemini CLI: o rodapé (ui.footer.items) só aceita itens nativos; o modo aparece como systemMessage do hook quando muda. "
|
|
65
|
+
"O Antigravity (agy, mesmo agente gemini do instalador) recebe agy-statusline. No ledger, agy é host agy, nunca gemini."),
|
|
66
|
+
"devin": ("Devin CLI não tem status line nem rodapé configurável; o modo aparece como systemMessage do hook quando muda. "
|
|
67
|
+
"Use o segmento tmux ou crow-skills progress para o resto."),
|
|
68
|
+
"codex": ("Codex: o rodapé só aceita itens nativos e não mostra o modo crow; ele aparece como systemMessage do hook no início "
|
|
69
|
+
"da sessão e quando muda."),
|
|
54
70
|
}
|
|
71
|
+
UNSUPPORTED_ROW = {"gemini": "gemini-cli-statusline", "devin": "devin-statusline", "codex": "codex-mode-indicator"}
|
|
55
72
|
|
|
56
73
|
|
|
57
74
|
def tmux_binary():
|
|
@@ -92,6 +109,14 @@ def script_source():
|
|
|
92
109
|
return (Path(__file__).resolve().parent / "progress.py").read_bytes()
|
|
93
110
|
|
|
94
111
|
|
|
112
|
+
def hook_script_path(store):
|
|
113
|
+
return root(store) / HOOK_SCRIPT
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def hook_source():
|
|
117
|
+
return (Path(__file__).resolve().parent / "hook.py").read_bytes()
|
|
118
|
+
|
|
119
|
+
|
|
95
120
|
def quoted_ok(*paths):
|
|
96
121
|
return all(not re.search(r"[\"'\\$`\n]", str(p)) for p in paths)
|
|
97
122
|
|
|
@@ -106,17 +131,6 @@ def backup(store, name, data):
|
|
|
106
131
|
return path.name
|
|
107
132
|
|
|
108
133
|
|
|
109
|
-
def restore_bytes(store, record, path, old):
|
|
110
|
-
"""Original bytes from the install backup, when the file still holds exactly what we wrote."""
|
|
111
|
-
name = record.get("backup")
|
|
112
|
-
if old is None or not name or record.get("written_sha") != sha(old) or "/" in name:
|
|
113
|
-
return None
|
|
114
|
-
try:
|
|
115
|
-
return read_regular(checked(root(store) / "backups" / name))
|
|
116
|
-
except (CrowError, OSError):
|
|
117
|
-
return None
|
|
118
|
-
|
|
119
|
-
|
|
120
134
|
def write(path, data, old):
|
|
121
135
|
mode = (path.lstat().st_mode & 0o777) if old is not None else 0o644
|
|
122
136
|
if not path.parent.exists():
|
|
@@ -124,19 +138,97 @@ def write(path, data, old):
|
|
|
124
138
|
write_atomic(path, data, mode)
|
|
125
139
|
|
|
126
140
|
|
|
127
|
-
#
|
|
141
|
+
# JSON files shared by integrations (claude settings.json holds the status line and the hooks)
|
|
142
|
+
# keep one restore point per file, state["files"][path]: the bytes before crow-skills first
|
|
143
|
+
# wrote it and the sha of what it last wrote. sync() binds these to the state being built.
|
|
144
|
+
_FILES = {}
|
|
145
|
+
_LIVE = {}
|
|
128
146
|
|
|
129
|
-
|
|
130
|
-
|
|
147
|
+
|
|
148
|
+
def tracked_write(store, path, data, old, label):
|
|
149
|
+
key = str(path)
|
|
150
|
+
entry = _FILES.get(key)
|
|
151
|
+
if entry is None:
|
|
152
|
+
entry = _FILES[key] = {"backup": backup(store, label, old), "created_file": old is None}
|
|
153
|
+
elif entry.get("written_sha") != sha(old or b""):
|
|
154
|
+
entry["backup"] = None # edited outside crow-skills since: from now on only our keys are removed
|
|
155
|
+
write(path, data, old)
|
|
156
|
+
entry["written_sha"] = sha(data)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def release(store, name, path, current, label, remaining):
|
|
160
|
+
"""Write the file without name's content. The last crow-skills user of an untouched file
|
|
161
|
+
gets the original bytes back (or the file removed, if crow-skills created it)."""
|
|
162
|
+
key = str(path)
|
|
163
|
+
entry = _FILES.get(key)
|
|
164
|
+
others = [n for n, r in _LIVE.items() if n != name and isinstance(r, dict) and r.get("path") == key]
|
|
165
|
+
backup(store, label, current)
|
|
166
|
+
if others:
|
|
167
|
+
data = dump(remaining)
|
|
168
|
+
write(path, data, current)
|
|
169
|
+
if entry is not None:
|
|
170
|
+
if entry.get("written_sha") != sha(current or b""):
|
|
171
|
+
entry["backup"] = None
|
|
172
|
+
entry["written_sha"] = sha(data)
|
|
173
|
+
return
|
|
174
|
+
_FILES.pop(key, None)
|
|
175
|
+
untouched = entry is not None and entry.get("written_sha") == sha(current or b"")
|
|
176
|
+
if untouched and entry.get("created_file"):
|
|
177
|
+
path.unlink()
|
|
178
|
+
return
|
|
179
|
+
if untouched and entry.get("backup") and "/" not in entry["backup"]:
|
|
180
|
+
try:
|
|
181
|
+
original = read_regular(checked(root(store) / "backups" / entry["backup"]))
|
|
182
|
+
except (CrowError, OSError):
|
|
183
|
+
original = None
|
|
184
|
+
if original is not None:
|
|
185
|
+
write(path, original, current)
|
|
186
|
+
return
|
|
187
|
+
if entry is not None and entry.get("created_file") and not remaining:
|
|
188
|
+
path.unlink()
|
|
189
|
+
return
|
|
190
|
+
write(path, dump(remaining), current)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- JSON status lines (claude, agy) --------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
STATUSLINES = {
|
|
196
|
+
"claude-statusline": {"path": (".claude", "settings.json"), "chain": "claude-statusline-chain.json",
|
|
197
|
+
"backup": "claude-settings", "label": "~/.claude/settings.json", "args": "",
|
|
198
|
+
"defaults": {"padding": 0}, "requires": None},
|
|
199
|
+
"agy-statusline": {"path": (".gemini", "antigravity-cli", "settings.json"), "chain": "agy-statusline-chain.json",
|
|
200
|
+
"backup": "agy-settings", "label": "~/.gemini/antigravity-cli/settings.json", "args": " --host agy",
|
|
201
|
+
"defaults": {"stack_with_default": True}, "requires": (".gemini", "antigravity-cli")},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def read_json_object(path, label):
|
|
206
|
+
"""(bytes, dict) of a JSON settings file, or (bytes, None, reason) when it cannot be edited safely."""
|
|
131
207
|
old = read_regular(path)
|
|
132
208
|
try:
|
|
133
|
-
|
|
209
|
+
data = json.loads(old.decode("utf-8")) if old is not None and old.strip() else {}
|
|
134
210
|
except (UnicodeDecodeError, ValueError):
|
|
135
|
-
return
|
|
136
|
-
if not isinstance(
|
|
137
|
-
return
|
|
211
|
+
return old, None, label + " não é JSON válido; nada alterado."
|
|
212
|
+
if not isinstance(data, dict):
|
|
213
|
+
return old, None, label + " não é um objeto JSON; nada alterado."
|
|
214
|
+
return old, data, None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def dump(data):
|
|
218
|
+
return (json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def statusline_sync(name, store, home, record, desired, python, dry):
|
|
222
|
+
spec = STATUSLINES[name]
|
|
223
|
+
if spec["requires"] and not (home.joinpath(*spec["requires"])).is_dir() and not record:
|
|
224
|
+
return None, "skip" if desired else "unchanged", (spec["label"] + ": CLI não configurado neste perfil; nada alterado."
|
|
225
|
+
if desired else None)
|
|
226
|
+
path = protect(home.joinpath(*spec["path"]), home)
|
|
227
|
+
old, settings, problem = read_json_object(path, spec["label"])
|
|
228
|
+
if problem:
|
|
229
|
+
return (record if desired else None), "skip", problem
|
|
138
230
|
current = settings.get("statusLine")
|
|
139
|
-
chain_file = root(store) / "
|
|
231
|
+
chain_file = root(store) / spec["chain"]
|
|
140
232
|
if not desired:
|
|
141
233
|
if not record:
|
|
142
234
|
return None, "unchanged", None
|
|
@@ -150,15 +242,7 @@ def claude_sync(store, home, record, desired, python, dry):
|
|
|
150
242
|
settings.pop("statusLine", None)
|
|
151
243
|
if dry:
|
|
152
244
|
return None, "remove", None
|
|
153
|
-
|
|
154
|
-
backup(store, "claude-settings", old)
|
|
155
|
-
if record.get("created_file") and not settings:
|
|
156
|
-
path.unlink()
|
|
157
|
-
elif original is not None:
|
|
158
|
-
# Nothing else changed since the install: give the file back byte for byte.
|
|
159
|
-
write(path, original, old)
|
|
160
|
-
else:
|
|
161
|
-
write(path, (json.dumps(settings, ensure_ascii=False, indent=2) + "\n").encode("utf-8"), old)
|
|
245
|
+
release(store, name, path, old, spec["backup"], settings)
|
|
162
246
|
if chain_file.exists():
|
|
163
247
|
chain_file.unlink()
|
|
164
248
|
return None, "remove", None
|
|
@@ -169,14 +253,14 @@ def claude_sync(store, home, record, desired, python, dry):
|
|
|
169
253
|
if record and current != record["value"]:
|
|
170
254
|
return record, "skip", "statusLine foi alterado depois da instalação; não sobrescrito. Rode crow-skills integrations uninstall para desfazer o controle."
|
|
171
255
|
if not record and current is not None:
|
|
172
|
-
if not (isinstance(current, dict) and current.get("type") == "command" and isinstance(current.get("command"), str)):
|
|
256
|
+
if not (isinstance(current, dict) and current.get("type", "command") == "command" and isinstance(current.get("command"), str)):
|
|
173
257
|
return None, "skip", "statusLine existente não é do tipo command; não encadeado nem substituído."
|
|
174
258
|
previous = current
|
|
175
|
-
command = shlex.quote(str(python)) + " " + shlex.quote(str(script)) + " statusline"
|
|
259
|
+
command = shlex.quote(str(python)) + " " + shlex.quote(str(script)) + " statusline" + spec["args"]
|
|
176
260
|
if previous is not None:
|
|
177
261
|
command += " --chain-file " + shlex.quote(str(chain_file))
|
|
178
262
|
# A chained status line keeps its own keys (padding and any other); only the command changes.
|
|
179
|
-
value = {k: v for k, v in previous.items() if k not in ("type", "command")} if previous is not None else
|
|
263
|
+
value = {k: v for k, v in previous.items() if k not in ("type", "command")} if previous is not None else dict(spec["defaults"])
|
|
180
264
|
value = {"type": "command", "command": command, **value}
|
|
181
265
|
if record and current == value:
|
|
182
266
|
return record, "unchanged", None
|
|
@@ -188,19 +272,278 @@ def claude_sync(store, home, record, desired, python, dry):
|
|
|
188
272
|
return new_record, action, None
|
|
189
273
|
if previous is not None:
|
|
190
274
|
atomic_json(chain_file, {"command": previous["command"]})
|
|
191
|
-
|
|
192
|
-
#
|
|
193
|
-
|
|
194
|
-
if record:
|
|
195
|
-
new_record["backup"] = record.get("backup") if record.get("written_sha") == sha(old or b"") else None
|
|
196
|
-
else:
|
|
197
|
-
new_record["backup"] = safety
|
|
198
|
-
data = (json.dumps(settings, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
199
|
-
write(path, data, old)
|
|
200
|
-
new_record["written_sha"] = sha(data)
|
|
275
|
+
# The restore point is the file's pre-install backup (state["files"]). An update keeps it only
|
|
276
|
+
# while the file still holds exactly what this CLI wrote; otherwise uninstall restores the key only.
|
|
277
|
+
tracked_write(store, path, dump(settings), old, spec["backup"])
|
|
201
278
|
return new_record, action, None
|
|
202
279
|
|
|
203
280
|
|
|
281
|
+
def claude_sync(store, home, record, desired, python, dry):
|
|
282
|
+
return statusline_sync("claude-statusline", store, home, record, desired, python, dry)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def agy_statusline_sync(store, home, record, desired, python, dry):
|
|
286
|
+
return statusline_sync("agy-statusline", store, home, record, desired, python, dry)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --- hooks (crow mode + events) --------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
# (event, async). Only the prompt event (and a SessionStart that shows the mode) wait for the
|
|
292
|
+
# hook; every other event is recorded asynchronously where the CLI supports it.
|
|
293
|
+
CLAUDE_EVENTS = [("SessionStart", True), ("UserPromptSubmit", False), ("PreToolUse", True), ("PostToolUse", True),
|
|
294
|
+
("PostToolUseFailure", True), ("PermissionRequest", True), ("Notification", True), ("SubagentStart", True),
|
|
295
|
+
("SubagentStop", True), ("Stop", True), ("StopFailure", True), ("PreCompact", True), ("SessionEnd", True)]
|
|
296
|
+
CODEX_EVENTS = [("SessionStart", False), ("UserPromptSubmit", False), ("PreToolUse", True), ("PostToolUse", True),
|
|
297
|
+
("PermissionRequest", True), ("SubagentStart", True), ("SubagentStop", True), ("Stop", True)]
|
|
298
|
+
GEMINI_EVENTS = ["SessionStart", "BeforeAgent", "BeforeTool", "AfterTool", "AfterAgent", "Notification", "PreCompress", "SessionEnd"]
|
|
299
|
+
AGY_EVENTS = ["SessionStart", "PreToolUse", "PostToolUse", "PreInvocation", "PostInvocation", "Stop"]
|
|
300
|
+
DEVIN_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "SubagentStop", "Stop",
|
|
301
|
+
"SessionEnd"]
|
|
302
|
+
AGY_KEY = "crow-skills"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def devin_dir(home):
|
|
306
|
+
if home == Path.home().resolve() and os.environ.get("XDG_CONFIG_HOME"):
|
|
307
|
+
return Path(os.environ["XDG_CONFIG_HOME"]).expanduser() / "devin"
|
|
308
|
+
return home / ".config" / "devin"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
HOOKS = {
|
|
312
|
+
"claude-hooks": {"host": "claude", "file": lambda h: h / ".claude" / "settings.json", "label": "~/.claude/settings.json",
|
|
313
|
+
"layout": "groups", "requires": None, "backup": "claude-settings"},
|
|
314
|
+
"codex-hooks": {"host": "codex", "file": lambda h: h / ".codex" / "hooks.json", "label": "~/.codex/hooks.json",
|
|
315
|
+
"layout": "groups", "requires": lambda h: h / ".codex", "backup": "codex-hooks"},
|
|
316
|
+
"gemini-hooks": {"host": "gemini", "file": lambda h: h / ".gemini" / "settings.json", "label": "~/.gemini/settings.json",
|
|
317
|
+
"layout": "groups", "requires": lambda h: h / ".gemini" / "settings.json", "backup": "gemini-settings"},
|
|
318
|
+
"agy-hooks": {"host": "agy", "file": lambda h: h / ".gemini" / "config" / "hooks.json", "label": "~/.gemini/config/hooks.json",
|
|
319
|
+
"layout": "agy", "requires": lambda h: h / ".gemini" / "antigravity-cli", "backup": "agy-hooks"},
|
|
320
|
+
"devin-hooks": {"host": "devin", "file": lambda h: devin_dir(h) / "config.json", "label": "~/.config/devin/config.json",
|
|
321
|
+
"layout": "groups", "requires": lambda h: devin_dir(h) / "config.json", "backup": "devin-config"},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def codex_hooks_feature(home):
|
|
326
|
+
"""Codex runs hooks only with [features] hooks = true; report it, never edit it here."""
|
|
327
|
+
try:
|
|
328
|
+
data = tomllib.loads((home / ".codex" / "config.toml").read_text(encoding="utf-8")) if tomllib else {}
|
|
329
|
+
except (OSError, ValueError):
|
|
330
|
+
data = {}
|
|
331
|
+
if (data.get("features") or {}).get("hooks") is True:
|
|
332
|
+
return ""
|
|
333
|
+
return " Habilite também [features] hooks = true em ~/.codex/config.toml (não alterado pelo crow-skills)."
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def hook_command(store, home, python, host, event):
|
|
337
|
+
return (shlex.quote(str(python)) + " -I -S " + shlex.quote(str(hook_script_path(store))) + " hook --host " + host +
|
|
338
|
+
" --event " + event + " --home " + shlex.quote(str(home)))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def hook_entries(store, home, python, host):
|
|
342
|
+
"""{event: [hook entry]} in the host's own schema."""
|
|
343
|
+
command = lambda event: hook_command(store, home, python, host, event) # noqa: E731
|
|
344
|
+
if host == "claude":
|
|
345
|
+
return {e: [dict({"type": "command", "command": command(e), "timeout": 5}, **({"async": True} if a else {}))]
|
|
346
|
+
for e, a in CLAUDE_EVENTS}
|
|
347
|
+
if host == "codex":
|
|
348
|
+
return {e: [dict({"type": "command", "command": command(e), "timeout": 5}, **({"async": True} if a else {}))]
|
|
349
|
+
for e, a in CODEX_EVENTS}
|
|
350
|
+
if host == "gemini":
|
|
351
|
+
return {e: [{"type": "command", "name": "crow-skills-" + e, "command": command(e), "timeout": 3000}] for e in GEMINI_EVENTS}
|
|
352
|
+
if host == "agy":
|
|
353
|
+
return {e: [{"type": "command", "command": command(e), "timeout": 3}] for e in AGY_EVENTS}
|
|
354
|
+
return {e: [{"type": "command", "command": command(e)}] for e in DEVIN_EVENTS}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def is_ours(entry, store):
|
|
358
|
+
return isinstance(entry, dict) and isinstance(entry.get("command"), str) and str(hook_script_path(store)) in entry["command"]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def strip_hooks(data, store, layout):
|
|
362
|
+
"""A copy of the settings without this CLI's hook entries (user entries keep their order)."""
|
|
363
|
+
result = deepcopy(data)
|
|
364
|
+
if layout == "agy":
|
|
365
|
+
block = result.get(AGY_KEY)
|
|
366
|
+
if isinstance(block, dict) and all(isinstance(v, list) and all(is_ours(e, store) for e in v) for v in block.values()):
|
|
367
|
+
result.pop(AGY_KEY)
|
|
368
|
+
return result
|
|
369
|
+
hooks = result.get("hooks")
|
|
370
|
+
if not isinstance(hooks, dict):
|
|
371
|
+
return result
|
|
372
|
+
for event in list(hooks):
|
|
373
|
+
groups = hooks[event]
|
|
374
|
+
if not isinstance(groups, list):
|
|
375
|
+
continue
|
|
376
|
+
kept = []
|
|
377
|
+
for group in groups:
|
|
378
|
+
if isinstance(group, dict) and isinstance(group.get("hooks"), list):
|
|
379
|
+
remaining = [h for h in group["hooks"] if not is_ours(h, store)]
|
|
380
|
+
if not remaining and group["hooks"]:
|
|
381
|
+
continue # a group that held only our entries
|
|
382
|
+
if len(remaining) != len(group["hooks"]):
|
|
383
|
+
group = dict(group, hooks=remaining)
|
|
384
|
+
kept.append(group)
|
|
385
|
+
if kept:
|
|
386
|
+
hooks[event] = kept
|
|
387
|
+
elif groups:
|
|
388
|
+
hooks.pop(event)
|
|
389
|
+
if not hooks and data.get("hooks"):
|
|
390
|
+
result.pop("hooks") # emptied by removing our entries: the key was ours to add
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def refresh_in_place(data, entries, store, layout):
|
|
395
|
+
"""Replace our existing entries where they are (same events, one entry each), so a re-sync
|
|
396
|
+
never moves the user's hooks or ours; None when the layout does not match the desired one."""
|
|
397
|
+
result = deepcopy(data)
|
|
398
|
+
if layout == "agy":
|
|
399
|
+
if not isinstance(result.get(AGY_KEY), dict):
|
|
400
|
+
return None
|
|
401
|
+
result[AGY_KEY] = entries # an existing key keeps its position
|
|
402
|
+
return result
|
|
403
|
+
hooks = result.get("hooks")
|
|
404
|
+
if not isinstance(hooks, dict):
|
|
405
|
+
return None
|
|
406
|
+
found = {}
|
|
407
|
+
for event, groups in hooks.items():
|
|
408
|
+
for gi, group in enumerate(groups if isinstance(groups, list) else []):
|
|
409
|
+
for hi, item in enumerate(group.get("hooks", []) if isinstance(group, dict) else []):
|
|
410
|
+
if is_ours(item, store):
|
|
411
|
+
found.setdefault(event, []).append((gi, hi))
|
|
412
|
+
if set(found) != set(entries) or any(len(v) != 1 or len(entries[e]) != 1 for e, v in found.items()):
|
|
413
|
+
return None
|
|
414
|
+
for event, [(gi, hi)] in found.items():
|
|
415
|
+
hooks[event][gi]["hooks"][hi] = entries[event][0]
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def add_hooks(data, entries, layout):
|
|
420
|
+
result = deepcopy(data)
|
|
421
|
+
if layout == "agy":
|
|
422
|
+
result[AGY_KEY] = entries
|
|
423
|
+
return result
|
|
424
|
+
hooks = result.setdefault("hooks", {})
|
|
425
|
+
for event, items in entries.items():
|
|
426
|
+
hooks.setdefault(event, []).append({"hooks": items}) # appended: the user's hooks keep their index (Codex trust)
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def hooks_sync(name, store, home, record, desired, python, dry):
|
|
431
|
+
spec = HOOKS[name]
|
|
432
|
+
target = spec["file"](home)
|
|
433
|
+
if desired and not record and spec["requires"] is not None and not os.path.exists(spec["requires"](home)):
|
|
434
|
+
return None, "skip", spec["label"] + ": CLI não configurado neste perfil; nada alterado."
|
|
435
|
+
path = protect(target, home)
|
|
436
|
+
old, data, problem = read_json_object(path, spec["label"])
|
|
437
|
+
if problem:
|
|
438
|
+
return (record if desired else None), "skip", problem
|
|
439
|
+
if spec["layout"] == "groups" and not isinstance(data.get("hooks", {}), dict):
|
|
440
|
+
return (record if desired else None), "skip", spec["label"] + ": chave hooks com formato inesperado; nada alterado."
|
|
441
|
+
stripped = strip_hooks(data, store, spec["layout"])
|
|
442
|
+
if not desired:
|
|
443
|
+
if not record:
|
|
444
|
+
return None, "unchanged", None
|
|
445
|
+
if stripped == data:
|
|
446
|
+
return None, "skip", "Entradas do crow-skills já ausentes de " + spec["label"] + "; controle encerrado."
|
|
447
|
+
if dry:
|
|
448
|
+
return None, "remove", None
|
|
449
|
+
release(store, name, path, old, spec["backup"], stripped)
|
|
450
|
+
return None, "remove", None
|
|
451
|
+
if not quoted_ok(python, hook_script_path(store), home):
|
|
452
|
+
return record, "skip", "Caminho do Python, do estado ou do home contém aspas/variáveis; hooks não configurados."
|
|
453
|
+
entries = hook_entries(store, home, python, spec["host"])
|
|
454
|
+
wanted = refresh_in_place(data, entries, store, spec["layout"]) or add_hooks(stripped, entries, spec["layout"])
|
|
455
|
+
if wanted == data:
|
|
456
|
+
if record:
|
|
457
|
+
return record, "unchanged", None
|
|
458
|
+
return {"path": str(path), "installed_at": now(), "created_file": False}, "adopt", None
|
|
459
|
+
action = "update" if record else "install"
|
|
460
|
+
new_record = {"path": str(path), "installed_at": now(),
|
|
461
|
+
"created_file": record.get("created_file", False) if record else old is None}
|
|
462
|
+
note = None
|
|
463
|
+
if spec["host"] == "codex":
|
|
464
|
+
note = ("Codex só executa hook novo ou alterado depois de aprovado: abra o Codex, rode /hooks e aprove as entradas "
|
|
465
|
+
"do crow-skills (o hook do usuário já aprovado continua no mesmo índice).") + codex_hooks_feature(home)
|
|
466
|
+
if dry:
|
|
467
|
+
return new_record, action, note
|
|
468
|
+
tracked_write(store, path, dump(wanted), old, spec["backup"])
|
|
469
|
+
return new_record, action, note
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# --- /crow slash commands ------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
COMMANDS = {
|
|
475
|
+
"claude-command": {"file": (".claude", "commands", "crow.md"), "requires": None, "host": "claude"},
|
|
476
|
+
"gemini-command": {"file": (".gemini", "commands", "crow.toml"), "requires": (".gemini", "settings.json"), "host": "gemini"},
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def command_content(name, store, home, python):
|
|
481
|
+
call = (shlex.quote(str(python)) + " -I -S " + shlex.quote(str(hook_script_path(store))) + " mode")
|
|
482
|
+
tail = " --home " + shlex.quote(str(home)) + " --by " + COMMANDS[name]["host"]
|
|
483
|
+
if name == "claude-command":
|
|
484
|
+
return ("---\n"
|
|
485
|
+
"description: Liga ou desliga o modo crow (crow-behaviour) em todas as sessões e CLIs. Uso: /crow on | off | status\n"
|
|
486
|
+
"argument-hint: on | off | status\n"
|
|
487
|
+
"allowed-tools: Bash(" + call + ":*)\n"
|
|
488
|
+
"disable-model-invocation: true\n"
|
|
489
|
+
"---\n"
|
|
490
|
+
"<!-- " + COMMAND_HEADER + " -->\n\n"
|
|
491
|
+
"!`case \"$ARGUMENTS\" in on|off|status|toggle|'') " + call + " $ARGUMENTS" + tail + " ;; *) echo 'uso: /crow on|off|status' ;; esac`\n\n"
|
|
492
|
+
"Responda ao usuário só com a linha acima (o estado do modo crow), sem carregar skills nem executar mais nada.\n")
|
|
493
|
+
return ("# " + COMMAND_HEADER + "\n"
|
|
494
|
+
'description = "Liga ou desliga o modo crow (crow-behaviour) em todas as sessões e CLIs: /crow on | off | status"\n'
|
|
495
|
+
'prompt = """\n'
|
|
496
|
+
"!{" + call + " {{args}}" + tail + "}\n\n"
|
|
497
|
+
"Responda ao usuário só com a linha acima (o estado do modo crow), sem carregar skills nem executar mais nada.\n"
|
|
498
|
+
'"""\n')
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def command_sync(name, store, home, record, desired, python, dry):
|
|
502
|
+
spec = COMMANDS[name]
|
|
503
|
+
path = home.joinpath(*spec["file"])
|
|
504
|
+
if desired and not record and spec["requires"] and not home.joinpath(*spec["requires"]).exists():
|
|
505
|
+
return None, "skip", "/".join(spec["requires"]) + " ausente: CLI não configurado neste perfil; nada alterado."
|
|
506
|
+
path = protect(path, home)
|
|
507
|
+
current = read_regular(path) if os.path.lexists(path) else None
|
|
508
|
+
managed = current is not None and COMMAND_HEADER.encode("utf-8") in current
|
|
509
|
+
if not desired:
|
|
510
|
+
if not record:
|
|
511
|
+
return None, "unchanged", None
|
|
512
|
+
if current is None or not managed or sha(current) != record.get("written_sha"):
|
|
513
|
+
return None, "skip", str(path) + " foi alterado ou removido; mantido como está e removido do controle do crow-skills."
|
|
514
|
+
if dry:
|
|
515
|
+
return None, "remove", None
|
|
516
|
+
path.unlink()
|
|
517
|
+
for directory in record.get("created_dirs", []):
|
|
518
|
+
try:
|
|
519
|
+
os.rmdir(directory)
|
|
520
|
+
except OSError:
|
|
521
|
+
pass
|
|
522
|
+
return None, "remove", None
|
|
523
|
+
if current is not None and not managed:
|
|
524
|
+
return record, "skip", str(path) + " existe e não é gerenciado; comando /crow não instalado."
|
|
525
|
+
if record and current is not None and sha(current) != record.get("written_sha"):
|
|
526
|
+
return record, "skip", str(path) + " foi editado depois da instalação; não sobrescrito."
|
|
527
|
+
if not quoted_ok(python, hook_script_path(store), home):
|
|
528
|
+
return record, "skip", "Caminho do Python, do estado ou do home contém aspas/variáveis; comando /crow não instalado."
|
|
529
|
+
content = command_content(name, store, home, python).encode("utf-8")
|
|
530
|
+
if record and current == content:
|
|
531
|
+
return record, "unchanged", None
|
|
532
|
+
created = record.get("created_dirs", []) if record else []
|
|
533
|
+
if not record:
|
|
534
|
+
parent = path.parent
|
|
535
|
+
while not parent.exists() and home in parent.parents:
|
|
536
|
+
created.append(str(parent))
|
|
537
|
+
parent = parent.parent
|
|
538
|
+
new_record = {"path": str(path), "installed_at": now(), "written_sha": sha(content), "created_dirs": created}
|
|
539
|
+
if dry:
|
|
540
|
+
return new_record, "update" if record else "install", None
|
|
541
|
+
if not path.parent.exists():
|
|
542
|
+
path.parent.mkdir(parents=True, mode=0o755)
|
|
543
|
+
write_atomic(path, content, 0o644)
|
|
544
|
+
return new_record, "update" if record else "install", None
|
|
545
|
+
|
|
546
|
+
|
|
204
547
|
# --- codex ---------------------------------------------------------------------------------
|
|
205
548
|
|
|
206
549
|
def marked(text):
|
|
@@ -448,7 +791,10 @@ def tmux_sync(store, home, record, desired, python, dry):
|
|
|
448
791
|
return new_record, action, note
|
|
449
792
|
|
|
450
793
|
|
|
451
|
-
SYNC = {"claude-statusline": claude_sync, "codex-statusline": codex_sync, "tmux-status": tmux_sync
|
|
794
|
+
SYNC = {"claude-statusline": claude_sync, "codex-statusline": codex_sync, "tmux-status": tmux_sync,
|
|
795
|
+
"agy-statusline": agy_statusline_sync,
|
|
796
|
+
**{name: (lambda n: lambda *a: hooks_sync(n, *a))(name) for name in HOOKS},
|
|
797
|
+
**{name: (lambda n: lambda *a: command_sync(n, *a))(name) for name in COMMANDS}}
|
|
452
798
|
|
|
453
799
|
|
|
454
800
|
# --- orchestration -------------------------------------------------------------------------
|
|
@@ -466,15 +812,27 @@ def sync(store, home, wanted, dry_run=False, python=None):
|
|
|
466
812
|
except CrowError as exc:
|
|
467
813
|
report["skipped"].append({"integration": "*", "reason": str(exc)})
|
|
468
814
|
return report
|
|
469
|
-
after = {"schema_version": 1, "integrations": dict(state["integrations"])}
|
|
815
|
+
after = {"schema_version": 1, "integrations": dict(state["integrations"]), "files": dict(state.get("files") or {})}
|
|
816
|
+
for record in state["integrations"].values():
|
|
817
|
+
# Records written by 0.7.x kept the restore point in the record itself.
|
|
818
|
+
if isinstance(record, dict) and record.get("written_sha") and record.get("path") and record["path"] not in after["files"] \
|
|
819
|
+
and str(record["path"]).endswith(".json"):
|
|
820
|
+
after["files"][record["path"]] = {"backup": record.get("backup"), "created_file": record.get("created_file", False),
|
|
821
|
+
"written_sha": record["written_sha"]}
|
|
822
|
+
global _FILES, _LIVE
|
|
823
|
+
_FILES = {k: dict(v) for k, v in after["files"].items()}
|
|
824
|
+
_LIVE = after["integrations"]
|
|
470
825
|
with (nullcontext() if dry_run else store.lock()):
|
|
471
826
|
if not dry_run and any(set(OWNERS[n]) & set(wanted) for n in NAMES):
|
|
472
|
-
script = script_path(store)
|
|
473
827
|
private_dir(root(store))
|
|
474
|
-
source
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
828
|
+
for script, source in ((script_path(store), script_source()), (hook_script_path(store), hook_source())):
|
|
829
|
+
if not script.exists() or sha(script.read_bytes()) != sha(source):
|
|
830
|
+
write_atomic(checked(script), source, 0o644)
|
|
831
|
+
# Removals first and in reverse order, then installs in order: two integrations that
|
|
832
|
+
# edit the same file (claude settings.json) restore it last-in, first-out, byte for byte.
|
|
833
|
+
order = [n for n in reversed(NAMES) if not set(OWNERS[n]) & set(wanted)] + \
|
|
834
|
+
[n for n in NAMES if set(OWNERS[n]) & set(wanted)]
|
|
835
|
+
for name in order:
|
|
478
836
|
desired = bool(set(OWNERS[name]) & set(wanted))
|
|
479
837
|
record = state["integrations"].get(name)
|
|
480
838
|
try:
|
|
@@ -493,13 +851,19 @@ def sync(store, home, wanted, dry_run=False, python=None):
|
|
|
493
851
|
after["integrations"].pop(name, None)
|
|
494
852
|
else:
|
|
495
853
|
after["integrations"][name] = new_record
|
|
854
|
+
after["files"] = {k: v for k, v in _FILES.items()
|
|
855
|
+
if any(isinstance(r, dict) and r.get("path") == k for r in after["integrations"].values())}
|
|
856
|
+
if not after["files"]:
|
|
857
|
+
after.pop("files")
|
|
496
858
|
if not dry_run:
|
|
497
859
|
if after != state:
|
|
498
860
|
atomic_json(state_path(store), after)
|
|
499
861
|
if not after["integrations"]:
|
|
500
|
-
script
|
|
501
|
-
|
|
502
|
-
|
|
862
|
+
for script in (script_path(store), hook_script_path(store)):
|
|
863
|
+
if script.exists():
|
|
864
|
+
script.unlink()
|
|
865
|
+
for key in ("changes", "skipped"):
|
|
866
|
+
report[key].sort(key=lambda c: NAMES.index(c["integration"]) if c["integration"] in NAMES else 99)
|
|
503
867
|
report["dry_run"] = dry_run
|
|
504
868
|
return report
|
|
505
869
|
|
|
@@ -516,5 +880,5 @@ def status(store, home):
|
|
|
516
880
|
row["note"] = "tmux.conf é symlink; linha source-file adicionada manualmente pelo usuário."
|
|
517
881
|
rows.append(row)
|
|
518
882
|
for agent, reason in UNSUPPORTED.items():
|
|
519
|
-
rows.append({"integration": agent
|
|
883
|
+
rows.append({"integration": UNSUPPORTED_ROW[agent], "agents": [agent], "status": "unsupported", "note": reason})
|
|
520
884
|
return rows
|