@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
|
@@ -10,17 +10,80 @@ import sys
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
-
from
|
|
13
|
+
from secure_fs import (
|
|
14
|
+
SecureDestination,
|
|
15
|
+
SecureTransaction,
|
|
16
|
+
lexical_absolute,
|
|
17
|
+
nearest_existing_root,
|
|
18
|
+
run_secure_transaction,
|
|
19
|
+
)
|
|
20
|
+
from skill_pointer import POINTER_SKILL_NAME, build_pointer_skill
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_managed(content: bytes | None) -> bool:
|
|
24
|
+
if content is None:
|
|
25
|
+
return False
|
|
26
|
+
return (
|
|
27
|
+
f"name: {POINTER_SKILL_NAME}".encode() in content
|
|
28
|
+
and b"This workspace uses ai-toolkit with Cline." in content
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _destination(target_dir: Path, skill_root: str) -> SecureDestination:
|
|
33
|
+
target = lexical_absolute(target_dir)
|
|
34
|
+
if target.is_symlink() or not target.is_dir():
|
|
35
|
+
raise RuntimeError(f"Unsafe Cline target directory: {target}")
|
|
36
|
+
path = target / skill_root / POINTER_SKILL_NAME / "SKILL.md"
|
|
37
|
+
return SecureDestination(path, nearest_existing_root(target), "Cline skill pointer")
|
|
14
38
|
|
|
15
39
|
|
|
16
40
|
def generate(target_dir: Path, *, emit_skill_pointer: bool = True,
|
|
17
41
|
skill_root: str = ".cline/skills") -> None:
|
|
18
42
|
if not emit_skill_pointer:
|
|
19
43
|
return
|
|
20
|
-
|
|
44
|
+
destination = _destination(target_dir, skill_root)
|
|
45
|
+
|
|
46
|
+
def apply(transaction: SecureTransaction) -> None:
|
|
47
|
+
current = transaction.initial_content(destination)
|
|
48
|
+
if current is not None and not _is_managed(current):
|
|
49
|
+
raise RuntimeError(f"Refusing user-owned Cline skill: {destination.path}")
|
|
50
|
+
transaction.atomic_write(
|
|
51
|
+
destination,
|
|
52
|
+
build_pointer_skill("Cline").encode("utf-8"),
|
|
53
|
+
0o644,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
run_secure_transaction([destination], apply)
|
|
21
57
|
print(f" Generated: {skill_root}/{POINTER_SKILL_NAME}/SKILL.md")
|
|
22
58
|
|
|
23
59
|
|
|
60
|
+
def discover(target_dir: Path, *, skill_root: str = ".cline/skills") -> int:
|
|
61
|
+
"""Return one when the managed Cline catalogue pointer is present."""
|
|
62
|
+
destination = _destination(target_dir, skill_root)
|
|
63
|
+
transaction = SecureTransaction([destination])
|
|
64
|
+
try:
|
|
65
|
+
return int(_is_managed(transaction.initial_content(destination)))
|
|
66
|
+
finally:
|
|
67
|
+
transaction.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cleanup(target_dir: Path, *, skill_root: str = ".cline/skills") -> int:
|
|
71
|
+
"""Remove only the managed Cline catalogue pointer."""
|
|
72
|
+
destination = _destination(target_dir, skill_root)
|
|
73
|
+
transaction = SecureTransaction([destination])
|
|
74
|
+
try:
|
|
75
|
+
if not _is_managed(transaction.initial_content(destination)):
|
|
76
|
+
return 0
|
|
77
|
+
transaction.materialize_parents()
|
|
78
|
+
transaction.unlink(destination)
|
|
79
|
+
return 1
|
|
80
|
+
except BaseException:
|
|
81
|
+
transaction.rollback()
|
|
82
|
+
raise
|
|
83
|
+
finally:
|
|
84
|
+
transaction.close()
|
|
85
|
+
|
|
86
|
+
|
|
24
87
|
def main() -> None:
|
|
25
88
|
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
26
89
|
generate(target)
|
|
@@ -61,6 +61,7 @@ SUPPORTED_EVENTS = frozenset(
|
|
|
61
61
|
"PreCompact",
|
|
62
62
|
"PostCompact",
|
|
63
63
|
"SessionStart",
|
|
64
|
+
"SessionEnd",
|
|
64
65
|
"UserPromptSubmit",
|
|
65
66
|
"SubagentStart",
|
|
66
67
|
"SubagentStop",
|
|
@@ -76,6 +77,7 @@ HANDLER_KEYS = frozenset(
|
|
|
76
77
|
"commandWindows",
|
|
77
78
|
"timeout",
|
|
78
79
|
"statusMessage",
|
|
80
|
+
"additionalContextLimit",
|
|
79
81
|
"async",
|
|
80
82
|
}
|
|
81
83
|
)
|
|
@@ -113,6 +115,9 @@ CODEX_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
113
115
|
"PreCompact": [
|
|
114
116
|
("", "codex-pre-compact.sh"),
|
|
115
117
|
],
|
|
118
|
+
"SessionEnd": [
|
|
119
|
+
("", "session-end.sh"),
|
|
120
|
+
],
|
|
116
121
|
"Stop": [
|
|
117
122
|
("", "quality-check.sh"),
|
|
118
123
|
("", "save-session.sh"),
|
|
@@ -166,6 +171,47 @@ if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/n
|
|
|
166
171
|
fi
|
|
167
172
|
"""
|
|
168
173
|
|
|
174
|
+
CODEX_SESSION_END_ADAPTER = r"""#!/usr/bin/env bash
|
|
175
|
+
# Native Codex SessionEnd handoff. SessionEnd output is advisory, so stay
|
|
176
|
+
# silent and persist the snapshot without attempting to steer the closed thread.
|
|
177
|
+
# The generated asset always ships these helpers beside this script.
|
|
178
|
+
# shellcheck disable=SC1091,SC2034,SC2153
|
|
179
|
+
# shellcheck source=_session-paths.sh
|
|
180
|
+
source "$(dirname "$0")/_session-paths.sh"
|
|
181
|
+
# shellcheck source=_hook-io.sh
|
|
182
|
+
source "$(dirname "$0")/_hook-io.sh"
|
|
183
|
+
|
|
184
|
+
INPUT=$(cat)
|
|
185
|
+
SESSION_ID=$(hook_session_id)
|
|
186
|
+
SESSION_STATE_CLI="${AI_TOOLKIT_SESSION_STATE_CLI:-$HOME/.softspark/ai-toolkit/scripts/session_state.py}"
|
|
187
|
+
if [ "$SESSION_ID" != "default" ] &&
|
|
188
|
+
[ -f "$SESSION_STATE_CLI" ] &&
|
|
189
|
+
[ ! -L "$SESSION_STATE_CLI" ] &&
|
|
190
|
+
command -v python3 >/dev/null 2>&1; then
|
|
191
|
+
python3 -S "$SESSION_STATE_CLI" clean \
|
|
192
|
+
--session-id "$SESSION_ID" >/dev/null 2>&1 || true
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
# shellcheck source=_profile-check.sh
|
|
196
|
+
source "$(dirname "$0")/_profile-check.sh"
|
|
197
|
+
|
|
198
|
+
STAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date)"
|
|
199
|
+
mkdir -p "$SESSION_DIR"
|
|
200
|
+
{
|
|
201
|
+
echo "# Session End Snapshot"
|
|
202
|
+
echo ""
|
|
203
|
+
echo "- ended_at: $STAMP"
|
|
204
|
+
if [ -f "$SESSION_CONTEXT_FILE" ]; then
|
|
205
|
+
echo "- session_context: present"
|
|
206
|
+
else
|
|
207
|
+
echo "- session_context: missing"
|
|
208
|
+
fi
|
|
209
|
+
echo "- next_start: re-read AGENTS.md, open tasks, and validation state"
|
|
210
|
+
} > "$SESSION_END_FILE"
|
|
211
|
+
|
|
212
|
+
exit 0
|
|
213
|
+
"""
|
|
214
|
+
|
|
169
215
|
CODEX_MCP_HEALTH_ADAPTER = r"""#!/usr/bin/env bash
|
|
170
216
|
# Check documented Codex MCP config layers without starting any MCP server.
|
|
171
217
|
cat >/dev/null || true
|
|
@@ -210,6 +256,7 @@ PY
|
|
|
210
256
|
GENERATED_ADAPTERS = {
|
|
211
257
|
"codex-stop-search-check.sh": CODEX_STOP_SEARCH_ADAPTER,
|
|
212
258
|
"codex-session-start.sh": CODEX_SESSION_START_ADAPTER,
|
|
259
|
+
"session-end.sh": CODEX_SESSION_END_ADAPTER,
|
|
213
260
|
"codex-pre-compact.sh": CODEX_PRE_COMPACT_ADAPTER,
|
|
214
261
|
"codex-mcp-health.sh": CODEX_MCP_HEALTH_ADAPTER,
|
|
215
262
|
}
|
|
@@ -248,13 +295,14 @@ def build_hooks_json(*, global_install: bool = False) -> dict[str, Any]:
|
|
|
248
295
|
for event, entries in CODEX_HOOKS.items():
|
|
249
296
|
groups: list[dict[str, Any]] = []
|
|
250
297
|
for matcher, script in entries:
|
|
298
|
+
handler: dict[str, Any] = {
|
|
299
|
+
"type": "command",
|
|
300
|
+
"command": _command_for(script, global_install),
|
|
301
|
+
}
|
|
302
|
+
if event == "SessionEnd":
|
|
303
|
+
handler["timeout"] = 3
|
|
251
304
|
group: dict[str, Any] = {
|
|
252
|
-
"hooks": [
|
|
253
|
-
{
|
|
254
|
-
"type": "command",
|
|
255
|
-
"command": _command_for(script, global_install),
|
|
256
|
-
}
|
|
257
|
-
]
|
|
305
|
+
"hooks": [handler]
|
|
258
306
|
}
|
|
259
307
|
if matcher:
|
|
260
308
|
group["matcher"] = matcher
|
|
@@ -266,8 +314,12 @@ def build_hooks_json(*, global_install: bool = False) -> dict[str, Any]:
|
|
|
266
314
|
|
|
267
315
|
|
|
268
316
|
def _validate_hooks_document(data: Any) -> None:
|
|
269
|
-
if not isinstance(data, dict) or set(data) - {"hooks"}:
|
|
270
|
-
raise ValueError(
|
|
317
|
+
if not isinstance(data, dict) or set(data) - {"description", "hooks"}:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
"Codex hooks.json supports only description and hooks at the top level"
|
|
320
|
+
)
|
|
321
|
+
if "description" in data and not isinstance(data["description"], str):
|
|
322
|
+
raise ValueError("Codex hooks.json description must be a string")
|
|
271
323
|
hooks = data.get("hooks", {})
|
|
272
324
|
if not isinstance(hooks, dict):
|
|
273
325
|
raise ValueError("Codex hooks.json hooks must be an object")
|
|
@@ -320,6 +372,14 @@ def _validate_handler(event: str, handler: Any) -> None:
|
|
|
320
372
|
timeout = handler["timeout"]
|
|
321
373
|
if type(timeout) is not int or timeout <= 0:
|
|
322
374
|
raise ValueError(f"Codex {event} timeout must be a positive integer")
|
|
375
|
+
if event == "SessionEnd" and timeout > 3:
|
|
376
|
+
raise ValueError("Codex SessionEnd timeout cannot exceed 3 seconds")
|
|
377
|
+
if "additionalContextLimit" in handler:
|
|
378
|
+
context_limit = handler["additionalContextLimit"]
|
|
379
|
+
if type(context_limit) is not int or context_limit < 0:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
f"Codex {event} additionalContextLimit must be a non-negative integer"
|
|
382
|
+
)
|
|
323
383
|
for key in ("commandWindows", "statusMessage"):
|
|
324
384
|
if key in handler and not isinstance(handler[key], str):
|
|
325
385
|
raise ValueError(f"Codex {event} {key} must be a string")
|
|
@@ -414,6 +474,8 @@ def _is_managed_handler(handler: dict[str, Any]) -> bool:
|
|
|
414
474
|
|
|
415
475
|
def _merge_hooks(existing: dict[str, Any], generated: dict[str, Any]) -> dict[str, Any]:
|
|
416
476
|
merged: dict[str, Any] = {"hooks": {}}
|
|
477
|
+
if "description" in existing:
|
|
478
|
+
merged["description"] = existing["description"]
|
|
417
479
|
for event, groups in existing.get("hooks", {}).items():
|
|
418
480
|
retained_groups: list[dict[str, Any]] = []
|
|
419
481
|
for group in groups:
|
|
@@ -0,0 +1,197 @@
|
|
|
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 Gemini CLI subagents under ``.gemini/agents``."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
|
|
16
|
+
from emission import agents_dir
|
|
17
|
+
from frontmatter import frontmatter_field
|
|
18
|
+
from secure_fs import (
|
|
19
|
+
SecureDestination,
|
|
20
|
+
SecureTransaction,
|
|
21
|
+
lexical_absolute,
|
|
22
|
+
nearest_existing_root,
|
|
23
|
+
run_secure_transaction,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
AGENT_PREFIX = "ai-toolkit-"
|
|
28
|
+
MANAGED_MARKER = "<!-- ai-toolkit-managed: gemini-agent -->"
|
|
29
|
+
SAFE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _body(source: Path) -> str:
|
|
33
|
+
text = source.read_text(encoding="utf-8")
|
|
34
|
+
if not text.startswith("---\n"):
|
|
35
|
+
return text.strip()
|
|
36
|
+
parts = text.split("---", 2)
|
|
37
|
+
return (parts[2] if len(parts) == 3 else text).strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _quote(value: str) -> str:
|
|
41
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_agent(source: Path) -> tuple[str, str]:
|
|
45
|
+
"""Return a Gemini agent name and its native Markdown definition."""
|
|
46
|
+
if source.is_symlink() or not source.is_file():
|
|
47
|
+
raise RuntimeError(f"Unsafe Gemini agent source: {source}")
|
|
48
|
+
name = frontmatter_field(source, "name")
|
|
49
|
+
description = frontmatter_field(source, "description")
|
|
50
|
+
if not SAFE_NAME.fullmatch(name):
|
|
51
|
+
raise ValueError(f"Invalid Gemini agent name in {source}: {name!r}")
|
|
52
|
+
if not description:
|
|
53
|
+
raise ValueError(f"Missing Gemini agent description in {source}")
|
|
54
|
+
lines = [
|
|
55
|
+
"---",
|
|
56
|
+
f"name: {name}",
|
|
57
|
+
f"description: {_quote(description)}",
|
|
58
|
+
"kind: local",
|
|
59
|
+
"---",
|
|
60
|
+
"",
|
|
61
|
+
MANAGED_MARKER,
|
|
62
|
+
"",
|
|
63
|
+
_body(source),
|
|
64
|
+
"",
|
|
65
|
+
]
|
|
66
|
+
return name, "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_managed(content: bytes | None) -> bool:
|
|
70
|
+
return content is not None and MANAGED_MARKER.encode() in content[:2048]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _stale_files(output_dir: Path, expected: set[str]) -> list[Path]:
|
|
74
|
+
if not output_dir.exists():
|
|
75
|
+
return []
|
|
76
|
+
if output_dir.is_symlink() or not output_dir.is_dir():
|
|
77
|
+
raise RuntimeError(f"Unsafe Gemini agents directory: {output_dir}")
|
|
78
|
+
stale: list[Path] = []
|
|
79
|
+
for candidate in sorted(output_dir.glob(f"{AGENT_PREFIX}*.md")):
|
|
80
|
+
if (
|
|
81
|
+
candidate.name in expected
|
|
82
|
+
or candidate.is_symlink()
|
|
83
|
+
or not candidate.is_file()
|
|
84
|
+
):
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
content = candidate.read_bytes()
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
if _is_managed(content):
|
|
91
|
+
stale.append(candidate)
|
|
92
|
+
return stale
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _source_plan(source_root: Path) -> list[tuple[str, str]]:
|
|
96
|
+
plan: list[tuple[str, str]] = []
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
for source in sorted(source_root.glob("*.md")):
|
|
99
|
+
name, rendered = render_agent(source)
|
|
100
|
+
if name in seen:
|
|
101
|
+
raise ValueError(f"Duplicate Gemini agent name: {name}")
|
|
102
|
+
seen.add(name)
|
|
103
|
+
plan.append((name, rendered))
|
|
104
|
+
return plan
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate(
|
|
108
|
+
target_dir: Path,
|
|
109
|
+
*,
|
|
110
|
+
source_dir: Path | None = None,
|
|
111
|
+
) -> tuple[int, int]:
|
|
112
|
+
"""Write all native Gemini agents and return ``(written, removed)``."""
|
|
113
|
+
source_root = lexical_absolute(source_dir or agents_dir)
|
|
114
|
+
if source_root.is_symlink() or not source_root.is_dir():
|
|
115
|
+
raise RuntimeError(f"Unsafe Gemini agents source directory: {source_root}")
|
|
116
|
+
plan = _source_plan(source_root)
|
|
117
|
+
target_dir = lexical_absolute(target_dir)
|
|
118
|
+
if target_dir.is_symlink() or not target_dir.is_dir():
|
|
119
|
+
raise RuntimeError(f"Unsafe Gemini target directory: {target_dir}")
|
|
120
|
+
gemini_dir = target_dir / ".gemini"
|
|
121
|
+
output_dir = gemini_dir / "agents"
|
|
122
|
+
if gemini_dir.is_symlink() or output_dir.is_symlink():
|
|
123
|
+
raise RuntimeError(f"Unsafe Gemini agents path: {output_dir}")
|
|
124
|
+
expected = {f"{AGENT_PREFIX}{name}.md" for name, _ in plan}
|
|
125
|
+
stale = _stale_files(output_dir, expected)
|
|
126
|
+
trusted_root = nearest_existing_root(target_dir)
|
|
127
|
+
writes = [
|
|
128
|
+
(
|
|
129
|
+
SecureDestination(
|
|
130
|
+
output_dir / f"{AGENT_PREFIX}{name}.md",
|
|
131
|
+
trusted_root,
|
|
132
|
+
f"Gemini agent {name}",
|
|
133
|
+
),
|
|
134
|
+
rendered.encode(),
|
|
135
|
+
)
|
|
136
|
+
for name, rendered in plan
|
|
137
|
+
]
|
|
138
|
+
stale_destinations = [
|
|
139
|
+
SecureDestination(path, trusted_root, f"stale Gemini agent {path.name}")
|
|
140
|
+
for path in stale
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
def apply(transaction: SecureTransaction) -> tuple[int, int]:
|
|
144
|
+
written = 0
|
|
145
|
+
removed = 0
|
|
146
|
+
for destination, content in writes:
|
|
147
|
+
existing = transaction.initial_content(destination)
|
|
148
|
+
if existing is not None and not _is_managed(existing):
|
|
149
|
+
continue
|
|
150
|
+
transaction.atomic_write(destination, content, 0o644)
|
|
151
|
+
written += 1
|
|
152
|
+
for destination in stale_destinations:
|
|
153
|
+
if _is_managed(transaction.initial_content(destination)):
|
|
154
|
+
transaction.unlink(destination)
|
|
155
|
+
removed += 1
|
|
156
|
+
return written, removed
|
|
157
|
+
|
|
158
|
+
return run_secure_transaction(
|
|
159
|
+
[destination for destination, _ in writes] + stale_destinations,
|
|
160
|
+
apply,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cleanup(target_dir: Path) -> int:
|
|
165
|
+
"""Remove only managed Gemini agent files from ``target_dir``."""
|
|
166
|
+
target = lexical_absolute(target_dir)
|
|
167
|
+
if target.is_symlink() or not target.is_dir():
|
|
168
|
+
raise RuntimeError(f"Unsafe Gemini target directory: {target}")
|
|
169
|
+
output_dir = target / ".gemini" / "agents"
|
|
170
|
+
stale = _stale_files(output_dir, set())
|
|
171
|
+
if not stale:
|
|
172
|
+
return 0
|
|
173
|
+
trusted_root = nearest_existing_root(target)
|
|
174
|
+
destinations = [
|
|
175
|
+
SecureDestination(path, trusted_root, f"Gemini agent {path.name}")
|
|
176
|
+
for path in stale
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
def apply(transaction: SecureTransaction) -> int:
|
|
180
|
+
removed = 0
|
|
181
|
+
for destination in destinations:
|
|
182
|
+
if _is_managed(transaction.initial_content(destination)):
|
|
183
|
+
transaction.unlink(destination)
|
|
184
|
+
removed += 1
|
|
185
|
+
return removed
|
|
186
|
+
|
|
187
|
+
return run_secure_transaction(destinations, apply)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main() -> None:
|
|
191
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
192
|
+
written, removed = generate(target)
|
|
193
|
+
print(f"Generated: .gemini/agents/ ({written} agents, {removed} stale removed)")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
main()
|
|
@@ -14,10 +14,10 @@ Gemini CLI hook events (per docs/hooks/reference.md, google-gemini/gemini-cli):
|
|
|
14
14
|
BeforeToolSelection, AfterModel, SessionStart, SessionEnd,
|
|
15
15
|
Notification, PreCompress
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`quality-check.sh` there.
|
|
17
|
+
Claude Code's source lifecycle includes `Stop`, but Gemini CLI does not
|
|
18
|
+
implement it. `AfterAgent` is the closest equivalent (fires once per turn
|
|
19
|
+
after the model's final response), so we wire our `save-session.sh` and
|
|
20
|
+
`quality-check.sh` there without advertising a non-existent Gemini event.
|
|
21
21
|
|
|
22
22
|
Hook scripts are shared with Claude Code / Codex and live under
|
|
23
23
|
`~/.softspark/ai-toolkit/hooks/`. This generator does NOT duplicate shell code.
|
|
@@ -37,6 +37,19 @@ from pathlib import Path
|
|
|
37
37
|
|
|
38
38
|
HOOKS_PREFIX = 'AI_TOOLKIT_HOOK_FORMAT=json "$HOME/.softspark/ai-toolkit/hooks/'
|
|
39
39
|
SOURCE_TAG = "ai-toolkit"
|
|
40
|
+
GEMINI_SUPPORTED_HOOK_EVENTS = (
|
|
41
|
+
"BeforeTool",
|
|
42
|
+
"AfterTool",
|
|
43
|
+
"BeforeToolSelection",
|
|
44
|
+
"BeforeAgent",
|
|
45
|
+
"AfterAgent",
|
|
46
|
+
"BeforeModel",
|
|
47
|
+
"AfterModel",
|
|
48
|
+
"Notification",
|
|
49
|
+
"PreCompress",
|
|
50
|
+
"SessionStart",
|
|
51
|
+
"SessionEnd",
|
|
52
|
+
)
|
|
40
53
|
|
|
41
54
|
# Event -> list of (matcher, script) pairs. Matcher semantics:
|
|
42
55
|
# - Tool events (BeforeTool/AfterTool): regex over tool_name
|
|
@@ -77,6 +90,13 @@ GEMINI_HOOKS: dict[str, list[tuple[str, str]]] = {
|
|
|
77
90
|
],
|
|
78
91
|
}
|
|
79
92
|
|
|
93
|
+
_unsupported_events = set(GEMINI_HOOKS) - set(GEMINI_SUPPORTED_HOOK_EVENTS)
|
|
94
|
+
if _unsupported_events:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"Unsupported Gemini hook event mapping(s): "
|
|
97
|
+
+ ", ".join(sorted(_unsupported_events))
|
|
98
|
+
)
|
|
99
|
+
|
|
80
100
|
|
|
81
101
|
def build_hook_entry(matcher: str, script: str) -> dict:
|
|
82
102
|
"""Build a single Gemini hook entry (merge-safe shape)."""
|