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