@softspark/ai-toolkit 4.24.0 → 4.25.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/CHANGELOG.md +66 -0
- package/README.md +35 -15
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/skills/hook-creator/SKILL.md +18 -4
- package/app/surface.json +4 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +67 -19
- package/bin/ai-toolkit.js +27 -3
- package/kb/reference/architecture-overview.md +13 -5
- package/kb/reference/claude-ecosystem-expansion-foundations.md +30 -5
- package/kb/reference/cli-reference.md +27 -2
- package/kb/reference/codex-cli-compatibility.md +101 -11
- package/kb/reference/global-install-model.md +10 -8
- package/kb/reference/hooks-catalog.md +41 -5
- package/kb/reference/mcp-editor-compatibility.md +3 -3
- package/kb/reference/mcp-templates.md +3 -3
- package/kb/reference/opencode-compatibility.md +53 -5
- package/kb/reference/supported-tools-registry.md +31 -26
- package/llms-full.txt +312 -73
- package/manifest.json +1 -1
- package/package.json +6 -2
- package/scripts/antigravity_plugin.py +570 -0
- package/scripts/codex_plugin.py +764 -0
- package/scripts/ecosystem_tools.json +92 -17
- package/scripts/generate_antigravity.py +16 -14
- package/scripts/generate_antigravity_agents.py +255 -0
- package/scripts/generate_antigravity_hooks.py +344 -0
- package/scripts/generate_cline_hooks.py +391 -0
- package/scripts/generate_cline_rules.py +210 -43
- package/scripts/generate_cline_skills.py +65 -2
- package/scripts/generate_codex_hooks.py +70 -8
- package/scripts/generate_gemini_agents.py +197 -0
- package/scripts/generate_gemini_hooks.py +24 -4
- package/scripts/generate_opencode_skills.py +544 -0
- package/scripts/inject_hook_cli.py +4 -26
- package/scripts/install.py +11 -10
- package/scripts/install_steps/ai_tools.py +209 -34
- package/scripts/mcp_editors.py +9 -1
- package/scripts/plugin.py +21 -0
- package/scripts/plugin_schema.py +8 -7
- package/scripts/secure_fs.py +35 -0
- package/scripts/uninstall.py +162 -18
- package/scripts/validate.py +42 -12
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
|
|
4
|
+
# Source: https://github.com/softspark/ai-toolkit
|
|
5
|
+
|
|
6
|
+
"""Generate native Google Antigravity hooks and their portable adapter.
|
|
7
|
+
|
|
8
|
+
Project installs use ``.agents/hooks.json`` and a workspace-relative runtime.
|
|
9
|
+
Global installs use ``~/.gemini/config/hooks.json`` and an adjacent runtime.
|
|
10
|
+
Only the top-level ``ai-toolkit`` namespace is owned by this generator.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
21
|
+
|
|
22
|
+
from secure_fs import (
|
|
23
|
+
SecureDestination,
|
|
24
|
+
SecureTransaction,
|
|
25
|
+
lexical_absolute,
|
|
26
|
+
nearest_existing_root,
|
|
27
|
+
run_secure_transaction,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MANAGED_NAMESPACE = "ai-toolkit"
|
|
32
|
+
HOOK_EVENTS = (
|
|
33
|
+
"PreToolUse",
|
|
34
|
+
"PostToolUse",
|
|
35
|
+
"PreInvocation",
|
|
36
|
+
"PostInvocation",
|
|
37
|
+
"Stop",
|
|
38
|
+
)
|
|
39
|
+
TOOL_EVENTS = frozenset({"PreToolUse", "PostToolUse"})
|
|
40
|
+
HOOK_TIMEOUT_SECONDS = 5
|
|
41
|
+
RUNTIME_NAME = "ai-toolkit-antigravity-hook.py"
|
|
42
|
+
RUNTIME_MARKER = "# ai-toolkit-managed: antigravity-hook-runtime"
|
|
43
|
+
TOOL_MATCHER = (
|
|
44
|
+
"run_command|write_to_file|replace_file_content|"
|
|
45
|
+
"multi_replace_file_content|view_file"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
RUNTIME_SOURCE = '''#!/usr/bin/env python3
|
|
50
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
51
|
+
# ai-toolkit-managed: antigravity-hook-runtime
|
|
52
|
+
"""Translate Antigravity hook input to bounded ai-toolkit safety decisions."""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import json
|
|
57
|
+
import re
|
|
58
|
+
import signal
|
|
59
|
+
import sys
|
|
60
|
+
from typing import Any
|
|
61
|
+
|
|
62
|
+
MAX_INPUT_BYTES = 1024 * 1024
|
|
63
|
+
MAX_RUNTIME_SECONDS = 4
|
|
64
|
+
EVENTS = {"PreToolUse", "PostToolUse", "PreInvocation", "PostInvocation", "Stop"}
|
|
65
|
+
DESTRUCTIVE = (
|
|
66
|
+
re.compile(r"(?:^|[;&|]\\s*)rm\\s+(?:-[^\\s]*r[^\\s]*f|-[^\\s]*f[^\\s]*r)\\b"),
|
|
67
|
+
re.compile(r"\\bgit\\s+reset\\s+--hard\\b"),
|
|
68
|
+
re.compile(r"\\bgit\\s+clean\\s+-[^\\s]*f"),
|
|
69
|
+
re.compile(r"\\b(?:mkfs|format)\\b", re.IGNORECASE),
|
|
70
|
+
re.compile(r"\\bDROP\\s+(?:DATABASE|SCHEMA|TABLE)\\b", re.IGNORECASE),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _timeout(_signum: int, _frame: object) -> None:
|
|
75
|
+
raise TimeoutError("Antigravity hook runtime exceeded its deadline")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_payload() -> dict[str, Any]:
|
|
79
|
+
raw = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1)
|
|
80
|
+
if len(raw) > MAX_INPUT_BYTES:
|
|
81
|
+
raise ValueError("Antigravity hook payload exceeds 1 MiB")
|
|
82
|
+
if not raw.strip():
|
|
83
|
+
return {}
|
|
84
|
+
value = json.loads(raw)
|
|
85
|
+
if not isinstance(value, dict):
|
|
86
|
+
raise ValueError("Antigravity hook payload must be an object")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _tool(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
91
|
+
tool_call = payload.get("toolCall", {})
|
|
92
|
+
if not isinstance(tool_call, dict):
|
|
93
|
+
return "", {}
|
|
94
|
+
name = tool_call.get("name", "")
|
|
95
|
+
args = tool_call.get("args", {})
|
|
96
|
+
return (
|
|
97
|
+
name if isinstance(name, str) else "",
|
|
98
|
+
args if isinstance(args, dict) else {},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pre_tool(payload: dict[str, Any]) -> dict[str, str]:
|
|
103
|
+
name, args = _tool(payload)
|
|
104
|
+
if name != "run_command":
|
|
105
|
+
return {"decision": "allow"}
|
|
106
|
+
command = args.get("CommandLine", "")
|
|
107
|
+
if not isinstance(command, str):
|
|
108
|
+
return {"decision": "ask"}
|
|
109
|
+
if any(pattern.search(command) for pattern in DESTRUCTIVE):
|
|
110
|
+
return {"decision": "deny"}
|
|
111
|
+
return {"decision": "allow"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def respond(event: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
115
|
+
if event == "PreToolUse":
|
|
116
|
+
return _pre_tool(payload)
|
|
117
|
+
if event == "PostToolUse":
|
|
118
|
+
return {}
|
|
119
|
+
if event == "PreInvocation":
|
|
120
|
+
return {
|
|
121
|
+
"injectSteps": [
|
|
122
|
+
{
|
|
123
|
+
"ephemeralMessage": (
|
|
124
|
+
"Follow repository instructions and verify before "
|
|
125
|
+
"claiming completion."
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
if event == "PostInvocation":
|
|
131
|
+
return {"injectSteps": [], "terminationBehavior": ""}
|
|
132
|
+
if event == "Stop":
|
|
133
|
+
execution_num = payload.get("executionNum")
|
|
134
|
+
is_first_execution = (
|
|
135
|
+
isinstance(execution_num, int)
|
|
136
|
+
and not isinstance(execution_num, bool)
|
|
137
|
+
and 0 <= execution_num <= 1
|
|
138
|
+
)
|
|
139
|
+
if payload.get("fullyIdle") is False and is_first_execution:
|
|
140
|
+
return {
|
|
141
|
+
"decision": "continue",
|
|
142
|
+
"reason": "The invocation is not fully idle; finish pending work.",
|
|
143
|
+
}
|
|
144
|
+
return {"decision": "stop"}
|
|
145
|
+
raise ValueError(f"unsupported Antigravity hook event: {event}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main() -> int:
|
|
149
|
+
if len(sys.argv) != 2 or sys.argv[1] not in EVENTS:
|
|
150
|
+
print("usage: ai-toolkit-antigravity-hook.py <event>", file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
signal.signal(signal.SIGALRM, _timeout)
|
|
153
|
+
signal.alarm(MAX_RUNTIME_SECONDS)
|
|
154
|
+
try:
|
|
155
|
+
result = respond(sys.argv[1], _read_payload())
|
|
156
|
+
print(json.dumps(result, separators=(",", ":"), sort_keys=True))
|
|
157
|
+
return 0
|
|
158
|
+
except (ValueError, json.JSONDecodeError, TimeoutError) as error:
|
|
159
|
+
print(str(error), file=sys.stderr)
|
|
160
|
+
return 1
|
|
161
|
+
finally:
|
|
162
|
+
signal.alarm(0)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
raise SystemExit(main())
|
|
167
|
+
'''
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _hook(command: str) -> dict[str, Any]:
|
|
171
|
+
return {"command": command, "timeout": HOOK_TIMEOUT_SECONDS}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def build_managed_hooks(command_prefix: str) -> dict[str, list[dict[str, Any]]]:
|
|
175
|
+
"""Return the exact managed namespace using Antigravity's native schema."""
|
|
176
|
+
managed: dict[str, list[dict[str, Any]]] = {}
|
|
177
|
+
for event in HOOK_EVENTS:
|
|
178
|
+
command = f"{command_prefix} {event}"
|
|
179
|
+
if event in TOOL_EVENTS:
|
|
180
|
+
managed[event] = [{"matcher": TOOL_MATCHER, "hooks": [_hook(command)]}]
|
|
181
|
+
else:
|
|
182
|
+
managed[event] = [_hook(command)]
|
|
183
|
+
return managed
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_document(command_prefix: str) -> dict[str, Any]:
|
|
187
|
+
return {MANAGED_NAMESPACE: build_managed_hooks(command_prefix)}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def validate_document(document: Any, command_prefix: str | None = None) -> None:
|
|
191
|
+
"""Reject schema drift before any config is written or packaged."""
|
|
192
|
+
if not isinstance(document, dict):
|
|
193
|
+
raise ValueError("Antigravity hooks.json must contain an object")
|
|
194
|
+
managed = document.get(MANAGED_NAMESPACE)
|
|
195
|
+
if not isinstance(managed, dict) or set(managed) != set(HOOK_EVENTS):
|
|
196
|
+
raise ValueError("Antigravity managed hooks must contain the exact event set")
|
|
197
|
+
for event in HOOK_EVENTS:
|
|
198
|
+
entries = managed[event]
|
|
199
|
+
if not isinstance(entries, list) or not entries:
|
|
200
|
+
raise ValueError(f"Antigravity {event} must contain hook entries")
|
|
201
|
+
handlers: list[Any]
|
|
202
|
+
if event in TOOL_EVENTS:
|
|
203
|
+
handlers = []
|
|
204
|
+
for group in entries:
|
|
205
|
+
if not isinstance(group, dict) or set(group) != {"matcher", "hooks"}:
|
|
206
|
+
raise ValueError(f"Antigravity {event} has invalid matcher group")
|
|
207
|
+
if not isinstance(group["matcher"], str) or not group["matcher"]:
|
|
208
|
+
raise ValueError(f"Antigravity {event} matcher must be non-empty")
|
|
209
|
+
if not isinstance(group["hooks"], list) or not group["hooks"]:
|
|
210
|
+
raise ValueError(f"Antigravity {event} hooks must be non-empty")
|
|
211
|
+
handlers.extend(group["hooks"])
|
|
212
|
+
else:
|
|
213
|
+
handlers = entries
|
|
214
|
+
for handler in handlers:
|
|
215
|
+
if not isinstance(handler, dict) or set(handler) != {"command", "timeout"}:
|
|
216
|
+
raise ValueError(f"Antigravity {event} handler must be command-only")
|
|
217
|
+
if not isinstance(handler["command"], str) or not handler["command"]:
|
|
218
|
+
raise ValueError(f"Antigravity {event} command must be non-empty")
|
|
219
|
+
if not isinstance(handler["timeout"], int) or not 1 <= handler["timeout"] <= 10:
|
|
220
|
+
raise ValueError(f"Antigravity {event} timeout must be bounded")
|
|
221
|
+
expected_command = f"{command_prefix} {event}"
|
|
222
|
+
if command_prefix is not None and handler["command"] != expected_command:
|
|
223
|
+
raise ValueError(f"Antigravity {event} command was tampered")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _load_existing(content: bytes | None, path: Path) -> dict[str, Any]:
|
|
227
|
+
if content is None:
|
|
228
|
+
return {}
|
|
229
|
+
try:
|
|
230
|
+
value = json.loads(content)
|
|
231
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
232
|
+
raise ValueError(f"Invalid Antigravity hooks JSON at {path}: {error}") from error
|
|
233
|
+
if not isinstance(value, dict):
|
|
234
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _paths(target_dir: Path, global_install: bool) -> tuple[Path, Path, str]:
|
|
239
|
+
if global_install:
|
|
240
|
+
config_root = target_dir / ".gemini" / "config"
|
|
241
|
+
runtime = config_root / "hooks" / RUNTIME_NAME
|
|
242
|
+
command = f'python3 "$HOME/.gemini/config/hooks/{RUNTIME_NAME}"'
|
|
243
|
+
return config_root / "hooks.json", runtime, command
|
|
244
|
+
config_root = target_dir / ".agents"
|
|
245
|
+
runtime = config_root / "hooks" / RUNTIME_NAME
|
|
246
|
+
command = f"python3 .agents/hooks/{RUNTIME_NAME}"
|
|
247
|
+
return config_root / "hooks.json", runtime, command
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def generate(target_dir: Path, *, global_install: bool = False) -> Path:
|
|
251
|
+
"""Merge hooks and write their adjacent runtime transactionally."""
|
|
252
|
+
target = lexical_absolute(target_dir)
|
|
253
|
+
if target.is_symlink() or not target.is_dir():
|
|
254
|
+
raise RuntimeError(f"Unsafe Antigravity target directory: {target}")
|
|
255
|
+
hooks_path, runtime_path, command_prefix = _paths(target, global_install)
|
|
256
|
+
root = nearest_existing_root(target)
|
|
257
|
+
hooks_destination = SecureDestination(hooks_path, root, "Antigravity hooks.json")
|
|
258
|
+
runtime_destination = SecureDestination(
|
|
259
|
+
runtime_path, root, "Antigravity hook runtime"
|
|
260
|
+
)
|
|
261
|
+
destinations = [hooks_destination, runtime_destination]
|
|
262
|
+
|
|
263
|
+
managed = build_managed_hooks(command_prefix)
|
|
264
|
+
validate_document({MANAGED_NAMESPACE: managed}, command_prefix)
|
|
265
|
+
|
|
266
|
+
def apply(transaction: SecureTransaction) -> None:
|
|
267
|
+
existing = _load_existing(
|
|
268
|
+
transaction.initial_content(hooks_destination), hooks_path
|
|
269
|
+
)
|
|
270
|
+
existing[MANAGED_NAMESPACE] = managed
|
|
271
|
+
validate_document(existing, command_prefix)
|
|
272
|
+
current_runtime = transaction.initial_content(runtime_destination)
|
|
273
|
+
if (
|
|
274
|
+
current_runtime is not None
|
|
275
|
+
and RUNTIME_MARKER.encode() not in current_runtime[:256]
|
|
276
|
+
):
|
|
277
|
+
raise RuntimeError(f"Refusing user-owned Antigravity runtime: {runtime_path}")
|
|
278
|
+
transaction.atomic_write(runtime_destination, RUNTIME_SOURCE.encode(), 0o755)
|
|
279
|
+
content = (
|
|
280
|
+
json.dumps(existing, indent=2, ensure_ascii=False, sort_keys=True)
|
|
281
|
+
+ "\n"
|
|
282
|
+
).encode()
|
|
283
|
+
transaction.atomic_write(hooks_destination, content, 0o600)
|
|
284
|
+
|
|
285
|
+
run_secure_transaction(destinations, apply)
|
|
286
|
+
return hooks_path
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def generate_global(home_dir: Path) -> Path:
|
|
290
|
+
return generate(home_dir, global_install=True)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cleanup(target_dir: Path, *, global_install: bool = False) -> None:
|
|
294
|
+
"""Remove only ai-toolkit's namespace and managed adjacent runtime."""
|
|
295
|
+
target = lexical_absolute(target_dir)
|
|
296
|
+
hooks_path, runtime_path, _ = _paths(target, global_install)
|
|
297
|
+
if not hooks_path.is_file() or hooks_path.is_symlink():
|
|
298
|
+
return
|
|
299
|
+
root = nearest_existing_root(target)
|
|
300
|
+
hooks_destination = SecureDestination(hooks_path, root, "Antigravity hooks.json")
|
|
301
|
+
destinations = [hooks_destination]
|
|
302
|
+
runtime_destination: SecureDestination | None = None
|
|
303
|
+
if runtime_path.is_file() and not runtime_path.is_symlink():
|
|
304
|
+
runtime_destination = SecureDestination(
|
|
305
|
+
runtime_path, root, "Antigravity hook runtime"
|
|
306
|
+
)
|
|
307
|
+
destinations.append(runtime_destination)
|
|
308
|
+
|
|
309
|
+
def apply(transaction: SecureTransaction) -> None:
|
|
310
|
+
document = _load_existing(
|
|
311
|
+
transaction.initial_content(hooks_destination), hooks_path
|
|
312
|
+
)
|
|
313
|
+
if MANAGED_NAMESPACE not in document:
|
|
314
|
+
return
|
|
315
|
+
document.pop(MANAGED_NAMESPACE)
|
|
316
|
+
if document:
|
|
317
|
+
transaction.atomic_write(
|
|
318
|
+
hooks_destination,
|
|
319
|
+
(
|
|
320
|
+
json.dumps(
|
|
321
|
+
document, indent=2, ensure_ascii=False, sort_keys=True
|
|
322
|
+
)
|
|
323
|
+
+ "\n"
|
|
324
|
+
).encode(),
|
|
325
|
+
)
|
|
326
|
+
else:
|
|
327
|
+
transaction.unlink(hooks_destination)
|
|
328
|
+
if runtime_destination is not None:
|
|
329
|
+
runtime = transaction.initial_content(runtime_destination)
|
|
330
|
+
if runtime is not None and RUNTIME_MARKER.encode() in runtime[:256]:
|
|
331
|
+
transaction.unlink(runtime_destination)
|
|
332
|
+
|
|
333
|
+
run_secure_transaction(destinations, apply)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main() -> None:
|
|
337
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
338
|
+
path = generate(target)
|
|
339
|
+
relative_path = path.relative_to(lexical_absolute(target))
|
|
340
|
+
print(f"Generated: {relative_path} ({len(HOOK_EVENTS)} events)")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
main()
|