@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +35 -15
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/skills/hook-creator/SKILL.md +18 -4
  5. package/app/surface.json +4 -0
  6. package/benchmarks/ecosystem-doctor-snapshot.json +67 -19
  7. package/bin/ai-toolkit.js +27 -3
  8. package/kb/reference/architecture-overview.md +13 -5
  9. package/kb/reference/claude-ecosystem-expansion-foundations.md +30 -5
  10. package/kb/reference/cli-reference.md +27 -2
  11. package/kb/reference/codex-cli-compatibility.md +101 -11
  12. package/kb/reference/global-install-model.md +10 -8
  13. package/kb/reference/hooks-catalog.md +41 -5
  14. package/kb/reference/mcp-editor-compatibility.md +3 -3
  15. package/kb/reference/mcp-templates.md +3 -3
  16. package/kb/reference/opencode-compatibility.md +53 -5
  17. package/kb/reference/supported-tools-registry.md +31 -26
  18. package/llms-full.txt +312 -73
  19. package/manifest.json +1 -1
  20. package/package.json +6 -2
  21. package/scripts/antigravity_plugin.py +570 -0
  22. package/scripts/codex_plugin.py +764 -0
  23. package/scripts/ecosystem_tools.json +92 -17
  24. package/scripts/generate_antigravity.py +16 -14
  25. package/scripts/generate_antigravity_agents.py +255 -0
  26. package/scripts/generate_antigravity_hooks.py +344 -0
  27. package/scripts/generate_cline_hooks.py +391 -0
  28. package/scripts/generate_cline_rules.py +210 -43
  29. package/scripts/generate_cline_skills.py +65 -2
  30. package/scripts/generate_codex_hooks.py +70 -8
  31. package/scripts/generate_gemini_agents.py +197 -0
  32. package/scripts/generate_gemini_hooks.py +24 -4
  33. package/scripts/generate_opencode_skills.py +544 -0
  34. package/scripts/inject_hook_cli.py +4 -26
  35. package/scripts/install.py +11 -10
  36. package/scripts/install_steps/ai_tools.py +209 -34
  37. package/scripts/mcp_editors.py +9 -1
  38. package/scripts/plugin.py +21 -0
  39. package/scripts/plugin_schema.py +8 -7
  40. package/scripts/secure_fs.py +35 -0
  41. package/scripts/uninstall.py +162 -18
  42. 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 skill_pointer import POINTER_SKILL_NAME, write_pointer_skill
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
- write_pointer_skill(target_dir, skill_root, "Cline")
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("Codex hooks.json must contain only the top-level hooks key")
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
- The ai-toolkit registry nominally includes `Stop`, but the upstream Gemini CLI
18
- does not implement it; `AfterAgent` is the closest equivalent (fires once per
19
- turn after the model's final response) and we wire our `save-session.sh` /
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)."""