@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
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.24.0",
2
+ "version": "4.25.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "4.24.0",
3
+ "version": "4.25.0",
4
4
  "description": "AI coding toolkit: 109 skills, 44 agents, 12 developer-tool integrations, recoverable native tool-output filtering, Claude Chat/Cowork export, safety constitution, SARIF audit, and signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -56,18 +56,22 @@
56
56
  "generate:windsurf": "python3 scripts/generate_windsurf.py > .windsurfrules",
57
57
  "generate:copilot": "AI_TOOLKIT_NO_CUSTOM_RULES=1 python3 scripts/generate_copilot.py > .github/copilot-instructions.md",
58
58
  "generate:gemini": "AI_TOOLKIT_NO_CUSTOM_RULES=1 python3 scripts/generate_gemini.py > GEMINI.md",
59
+ "generate:gemini-agents": "python3 scripts/generate_gemini_agents.py .",
59
60
  "generate:cline": "python3 scripts/generate_cline_rules.py .",
61
+ "generate:cline-hooks": "python3 scripts/generate_cline_hooks.py .",
60
62
  "generate:roo": "python3 scripts/generate_roo_modes.py > .roomodes",
61
63
  "generate:aider": "python3 scripts/generate_aider_conf.py > .aider.conf.yml",
62
64
  "generate:language-rules": "python3 scripts/generate_language_rules_skills.py",
63
65
  "generate:opencode-agents": "python3 scripts/generate_opencode_agents.py .",
64
66
  "generate:opencode-commands": "python3 scripts/generate_opencode_commands.py .",
67
+ "generate:opencode-skills": "python3 scripts/generate_opencode_skills.py .",
65
68
  "generate:cursor-mdc": "python3 scripts/generate_cursor_mdc.py .",
66
69
  "generate:windsurf-rules": "python3 scripts/generate_windsurf_rules.py .",
67
70
  "generate:roo-rules": "python3 scripts/generate_roo_rules.py .",
68
71
  "generate:augment-rules": "python3 scripts/generate_augment_rules.py .",
72
+ "generate:antigravity": "python3 scripts/generate_antigravity.py . && python3 scripts/generate_antigravity_hooks.py . && python3 scripts/generate_antigravity_agents.py .",
69
73
  "generate:claude-app": "python3 scripts/claude_app.py sync",
70
- "generate:all": "npm run generate:language-rules && npm run generate:claude-app && npm run generate:agents && npm run generate:opencode-agents && npm run generate:opencode-commands && npm run generate:cursor && npm run generate:cursor-mdc && npm run generate:windsurf && npm run generate:windsurf-rules && npm run generate:copilot && npm run generate:gemini && npm run generate:cline && npm run generate:roo && npm run generate:roo-rules && npm run generate:aider && npm run generate:augment-rules && npm run generate:llms && npm run generate:badges",
74
+ "generate:all": "npm run generate:language-rules && npm run generate:claude-app && npm run generate:agents && npm run generate:opencode-agents && npm run generate:opencode-commands && npm run generate:opencode-skills && npm run generate:cursor && npm run generate:cursor-mdc && npm run generate:windsurf && npm run generate:windsurf-rules && npm run generate:copilot && npm run generate:gemini && npm run generate:gemini-agents && npm run generate:cline && npm run generate:cline-hooks && npm run generate:roo && npm run generate:roo-rules && npm run generate:aider && npm run generate:augment-rules && npm run generate:antigravity && npm run generate:llms && npm run generate:badges",
71
75
  "generate:badges": "python3 scripts/sync_badges.py",
72
76
  "surface": "python3 scripts/surface_manifest.py",
73
77
  "surface:update": "python3 scripts/surface_manifest.py --update"
@@ -0,0 +1,570 @@
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
+ """Export and verify the opt-in native Google Antigravity plugin package."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import io
12
+ import json
13
+ import os
14
+ import re
15
+ import stat
16
+ import sys
17
+ import tempfile
18
+ import zipfile
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+ from dir_rules_shared import STANDARD_RULES
25
+ from emission import agents_dir
26
+ from generate_antigravity_agents import render_agent
27
+ from generate_antigravity_hooks import (
28
+ RUNTIME_NAME,
29
+ RUNTIME_SOURCE,
30
+ build_document,
31
+ validate_document,
32
+ )
33
+ from secure_fs import SecureDestination, lexical_absolute, run_secure_transaction
34
+
35
+
36
+ TOOLKIT_DIR = Path(__file__).resolve().parent.parent
37
+ APP_DIR = TOOLKIT_DIR / "app"
38
+ PLUGIN_NAME = "ai-toolkit"
39
+ PLUGIN_SCHEMA = "https://antigravity.google/schemas/v1/plugin.json"
40
+ PLUGIN_HOOK_COMMAND = f'python3 "${{extensionPath}}/runtime/{RUNTIME_NAME}"'
41
+ FIXED_ZIP_TIME = (2026, 1, 1, 0, 0, 0)
42
+ SKIP_NAMES = frozenset({"__pycache__", ".DS_Store"})
43
+ ABSOLUTE_HOST_PATH = re.compile(r"(?:/Users/|/home/|[A-Za-z]:\\\\Users\\\\)")
44
+ PLUGIN_SCRIPT_NAMES = (
45
+ "_common.py",
46
+ "audit_skills.py",
47
+ "emission.py",
48
+ "frontmatter.py",
49
+ "injection.py",
50
+ "instruction_core.py",
51
+ )
52
+
53
+
54
+ def render_manifest() -> str:
55
+ manifest = {
56
+ "$schema": PLUGIN_SCHEMA,
57
+ "name": PLUGIN_NAME,
58
+ "description": (
59
+ "AI engineering rules, skills, agents, and lifecycle guardrails."
60
+ ),
61
+ }
62
+ return json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
63
+
64
+
65
+ def _collect_tree(source: Path, prefix: str) -> list[tuple[str, bytes, int]]:
66
+ """Collect regular files without following links or special entries."""
67
+ source = lexical_absolute(source)
68
+ if source.is_symlink() or not source.is_dir():
69
+ raise RuntimeError(f"unsafe plugin source directory: {source}")
70
+ collected: list[tuple[str, bytes, int]] = []
71
+ for path in sorted(source.rglob("*")):
72
+ relative = path.relative_to(source)
73
+ if any(
74
+ part in SKIP_NAMES or part.endswith(".pyc")
75
+ for part in relative.parts
76
+ ):
77
+ continue
78
+ if path.is_symlink():
79
+ raise RuntimeError(f"plugin source symlink is not allowed: {path}")
80
+ if path.is_dir():
81
+ continue
82
+ if not path.is_file():
83
+ raise RuntimeError(f"unsupported plugin source entry: {path}")
84
+ mode = 0o755 if stat.S_IMODE(path.stat().st_mode) & 0o111 else 0o644
85
+ collected.append(
86
+ ((Path(prefix) / relative).as_posix(), path.read_bytes(), mode)
87
+ )
88
+ return collected
89
+
90
+
91
+ def _adapt_skill_files(
92
+ files: list[tuple[str, bytes, int]],
93
+ ) -> list[tuple[str, bytes, int]]:
94
+ """Rewrite source-checkout paths to resources bundled in the plugin."""
95
+ adapted: list[tuple[str, bytes, int]] = []
96
+ for relative, content, mode in files:
97
+ path = Path(relative)
98
+ if path.name != "SKILL.md" or len(path.parts) < 3:
99
+ adapted.append((relative, content, mode))
100
+ continue
101
+ skill_name = path.parts[1]
102
+ text = content.decode("utf-8")
103
+ skill_root = f"${{extensionPath}}/skills/{skill_name}"
104
+ text = text.replace("${CLAUDE_SKILL_DIR}", skill_root)
105
+ if skill_name == "persona":
106
+ installed_personas = (
107
+ "~/.claude/skills/persona/../../../app/personas/"
108
+ )
109
+ text = text.replace(
110
+ installed_personas,
111
+ "${extensionPath}/resources/personas/",
112
+ )
113
+ text = text.replace(
114
+ "app/personas/",
115
+ "${extensionPath}/resources/personas/",
116
+ )
117
+ elif skill_name == "docs":
118
+ text = text.replace(
119
+ "app/skills/documentation-standards/SKILL.md",
120
+ "${extensionPath}/skills/documentation-standards/SKILL.md",
121
+ )
122
+ elif skill_name == "briefing":
123
+ text = text.replace(
124
+ "scripts/session_token_stats.py",
125
+ "${extensionPath}/skills/briefing/scripts/"
126
+ "session_token_stats.py",
127
+ )
128
+ elif skill_name == "skill-audit":
129
+ text = text.replace(
130
+ "scripts/audit_skills.py",
131
+ "${extensionPath}/scripts/audit_skills.py",
132
+ )
133
+ adapted.append((relative, text.encode("utf-8"), mode))
134
+ return adapted
135
+
136
+
137
+ def _plugin_audit_helper_content(source: Path) -> bytes:
138
+ """Adapt the security auditor to the native plugin directory layout."""
139
+ text = source.read_text(encoding="utf-8")
140
+ replacements = {
141
+ 'skills = toolkit_root / "app" / "skills"': (
142
+ 'skills = toolkit_root / "skills"'
143
+ ),
144
+ 'skills = app / "skills"': 'skills = toolkit_root / "skills"',
145
+ 'agents = app / "agents"': 'agents = toolkit_root / "agents"',
146
+ 'agents.glob("*.md")': 'agents.glob("*/agent.md")',
147
+ }
148
+ for original, replacement in replacements.items():
149
+ if text.count(original) != 1:
150
+ raise RuntimeError(
151
+ f"audit_skills.py layout marker changed: {original}"
152
+ )
153
+ text = text.replace(original, replacement, 1)
154
+ return text.encode("utf-8")
155
+
156
+
157
+ def _plugin_agent_text(rendered: str) -> str:
158
+ """Point agent runtime commands at resources inside this plugin."""
159
+ return rendered.replace(
160
+ "${SKILL_DIR}/cve-scan/scripts/cve_scan.py",
161
+ "${extensionPath}/skills/cve-scan/scripts/cve_scan.py",
162
+ ).replace(
163
+ "app/skills/cve-scan/scripts/cve_scan.py",
164
+ "${extensionPath}/skills/cve-scan/scripts/cve_scan.py",
165
+ )
166
+
167
+
168
+ def _plugin_files() -> list[tuple[str, bytes, int]]:
169
+ """Build the complete package in memory before touching the destination."""
170
+ files: list[tuple[str, bytes, int]] = [
171
+ ("plugin.json", render_manifest().encode(), 0o644),
172
+ (
173
+ "hooks.json",
174
+ (
175
+ json.dumps(
176
+ build_document(PLUGIN_HOOK_COMMAND),
177
+ indent=2,
178
+ ensure_ascii=False,
179
+ sort_keys=True,
180
+ )
181
+ + "\n"
182
+ ).encode(),
183
+ 0o644,
184
+ ),
185
+ (f"runtime/{RUNTIME_NAME}", RUNTIME_SOURCE.encode(), 0o755),
186
+ ]
187
+ for filename, builder in sorted(STANDARD_RULES.items()):
188
+ files.append((f"rules/{filename}", builder().encode(), 0o644))
189
+ files.extend(
190
+ _adapt_skill_files(_collect_tree(APP_DIR / "skills", "skills"))
191
+ )
192
+ files.extend(_collect_tree(APP_DIR / "personas", "resources/personas"))
193
+ token_stats = TOOLKIT_DIR / "scripts" / "session_token_stats.py"
194
+ if token_stats.is_symlink() or not token_stats.is_file():
195
+ raise RuntimeError("briefing runtime source must be a regular file")
196
+ files.append(
197
+ (
198
+ "skills/briefing/scripts/session_token_stats.py",
199
+ token_stats.read_bytes(),
200
+ 0o755,
201
+ )
202
+ )
203
+ for script_name in PLUGIN_SCRIPT_NAMES:
204
+ source = TOOLKIT_DIR / "scripts" / script_name
205
+ if source.is_symlink() or not source.is_file():
206
+ raise RuntimeError(f"unsafe plugin script source: {source}")
207
+ content = (
208
+ _plugin_audit_helper_content(source)
209
+ if script_name == "audit_skills.py"
210
+ else source.read_bytes()
211
+ )
212
+ mode = 0o755 if script_name == "audit_skills.py" else 0o644
213
+ files.append((f"scripts/{script_name}", content, mode))
214
+ for source in sorted(agents_dir.glob("*.md")):
215
+ name, rendered = render_agent(source)
216
+ files.append(
217
+ (
218
+ f"agents/ai-toolkit-{name}/agent.md",
219
+ _plugin_agent_text(rendered).encode(),
220
+ 0o644,
221
+ )
222
+ )
223
+ license_path = TOOLKIT_DIR / "LICENSE"
224
+ if license_path.is_symlink() or not license_path.is_file():
225
+ raise RuntimeError("plugin LICENSE source must be a regular file")
226
+ files.append(("LICENSE", license_path.read_bytes(), 0o644))
227
+ names = [name for name, _, _ in files]
228
+ if len(names) != len(set(names)):
229
+ raise RuntimeError("duplicate Antigravity plugin resource")
230
+ return sorted(files, key=lambda item: item[0])
231
+
232
+
233
+ def _canonical_file_map() -> dict[str, tuple[bytes, int]]:
234
+ return {
235
+ relative: (content, mode)
236
+ for relative, content, mode in _plugin_files()
237
+ }
238
+
239
+
240
+ def _validate_canonical_directory(plugin_dir: Path, errors: list[str]) -> None:
241
+ expected = _canonical_file_map()
242
+ expected_directories = {
243
+ parent.as_posix()
244
+ for relative in expected
245
+ for parent in Path(relative).parents
246
+ if parent != Path(".")
247
+ }
248
+ actual = {
249
+ path.relative_to(plugin_dir).as_posix(): path
250
+ for path in plugin_dir.rglob("*")
251
+ if path.is_file() and not path.is_symlink()
252
+ }
253
+ actual_directories = {
254
+ path.relative_to(plugin_dir).as_posix()
255
+ for path in plugin_dir.rglob("*")
256
+ if path.is_dir() and not path.is_symlink()
257
+ }
258
+ missing = sorted(set(expected) - set(actual))
259
+ extra = sorted(set(actual) - set(expected))
260
+ if missing:
261
+ errors.append(f"plugin canonical resources are missing: {missing}")
262
+ if extra:
263
+ errors.append(f"plugin has unexpected resources: {extra}")
264
+ extra_directories = sorted(actual_directories - expected_directories)
265
+ if extra_directories:
266
+ errors.append(
267
+ f"plugin has unexpected directories: {extra_directories}"
268
+ )
269
+ for relative in sorted(set(expected) & set(actual)):
270
+ expected_content, expected_mode = expected[relative]
271
+ path = actual[relative]
272
+ if path.read_bytes() != expected_content:
273
+ errors.append(f"plugin resource bytes differ: {relative}")
274
+ actual_mode = stat.S_IMODE(path.stat().st_mode)
275
+ if actual_mode != expected_mode:
276
+ errors.append(
277
+ f"plugin resource mode differs: {relative} "
278
+ f"({actual_mode:o} != {expected_mode:o})"
279
+ )
280
+
281
+
282
+ def _zip_bytes(files: list[tuple[str, bytes, int]]) -> bytes:
283
+ output = io.BytesIO()
284
+ with zipfile.ZipFile(output, "w") as archive:
285
+ for relative, content, mode in files:
286
+ info = zipfile.ZipInfo(f"{PLUGIN_NAME}/{relative}", FIXED_ZIP_TIME)
287
+ info.compress_type = zipfile.ZIP_DEFLATED
288
+ info.external_attr = (stat.S_IFREG | mode) << 16
289
+ archive.writestr(info, content)
290
+ return output.getvalue()
291
+
292
+
293
+ def _assert_safe_output(output: Path) -> None:
294
+ current = Path(output.anchor)
295
+ for part in output.parts[1:-1]:
296
+ current /= part
297
+ if current.is_symlink():
298
+ raise RuntimeError(f"refusing symlinked output ancestor: {current}")
299
+ if current.exists() and not current.is_dir():
300
+ raise RuntimeError(f"refusing non-directory output ancestor: {current}")
301
+ if output.is_symlink() or (output.exists() and not output.is_file()):
302
+ raise RuntimeError(f"refusing unsafe plugin output: {output}")
303
+
304
+
305
+ def _normalize_macos_var_alias(output: Path) -> Path:
306
+ """Use macOS' real ``/private/var`` path without blessing other symlinks."""
307
+ var_root = Path("/var")
308
+ if (
309
+ output.parts[:2] == ("/", "var")
310
+ and var_root.is_symlink()
311
+ and var_root.resolve() == Path("/private/var")
312
+ ):
313
+ return Path("/private/var", *output.parts[2:])
314
+ return output
315
+
316
+
317
+ def export(output: str | os.PathLike[str]) -> Path:
318
+ """Create a deterministic ZIP with transactional destination replacement."""
319
+ destination_path = _normalize_macos_var_alias(lexical_absolute(output))
320
+ _assert_safe_output(destination_path)
321
+ archive = _zip_bytes(_plugin_files())
322
+ if os.environ.get("AI_TOOLKIT_ANTIGRAVITY_PLUGIN_INJECT_FAILURE") == "1":
323
+ raise RuntimeError("injected Antigravity plugin export failure")
324
+ destination = SecureDestination(
325
+ destination_path,
326
+ Path(destination_path.anchor),
327
+ "Antigravity plugin archive",
328
+ )
329
+ run_secure_transaction(
330
+ [destination],
331
+ lambda transaction: transaction.atomic_write(
332
+ destination, archive, 0o644
333
+ ),
334
+ )
335
+ return destination_path
336
+
337
+
338
+ def _safe_zip_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
339
+ members = archive.infolist()
340
+ names = [member.filename for member in members]
341
+ if len(names) != len(set(names)):
342
+ raise ValueError("plugin archive contains duplicate entries")
343
+ for member in members:
344
+ path = Path(member.filename)
345
+ if path.is_absolute() or ".." in path.parts:
346
+ raise ValueError(f"plugin archive path traversal: {member.filename}")
347
+ mode = member.external_attr >> 16
348
+ kind = stat.S_IFMT(mode)
349
+ if kind not in (0, stat.S_IFREG, stat.S_IFDIR):
350
+ raise ValueError(f"plugin archive special entry: {member.filename}")
351
+ if stat.S_ISLNK(mode):
352
+ raise ValueError(f"plugin archive symlink: {member.filename}")
353
+ permissions = stat.S_IMODE(mode)
354
+ if permissions & 0o022:
355
+ raise ValueError(f"plugin archive unsafe mode: {member.filename}")
356
+ return members
357
+
358
+
359
+ def _validate_canonical_archive(archive: zipfile.ZipFile) -> list[str]:
360
+ errors: list[str] = []
361
+ members = _safe_zip_members(archive)
362
+ expected = {
363
+ f"{PLUGIN_NAME}/{relative}": (content, mode)
364
+ for relative, (content, mode) in _canonical_file_map().items()
365
+ }
366
+ actual = {member.filename: member for member in members}
367
+ missing = sorted(set(expected) - set(actual))
368
+ extra = sorted(set(actual) - set(expected))
369
+ if missing:
370
+ errors.append(f"plugin archive members are missing: {missing}")
371
+ if extra:
372
+ errors.append(f"plugin archive has unexpected members: {extra}")
373
+ for name in sorted(set(expected) & set(actual)):
374
+ expected_content, expected_mode = expected[name]
375
+ member = actual[name]
376
+ if archive.read(member) != expected_content:
377
+ errors.append(f"plugin archive member bytes differ: {name}")
378
+ actual_mode = stat.S_IMODE(member.external_attr >> 16)
379
+ if actual_mode != expected_mode:
380
+ errors.append(
381
+ f"plugin archive member mode differs: {name} "
382
+ f"({actual_mode:o} != {expected_mode:o})"
383
+ )
384
+ return errors
385
+
386
+
387
+ def _extract_archive(archive_path: Path, destination: Path) -> Path:
388
+ with zipfile.ZipFile(archive_path) as archive:
389
+ members = _safe_zip_members(archive)
390
+ for member in members:
391
+ relative = Path(member.filename)
392
+ target = destination / relative
393
+ if member.is_dir():
394
+ target.mkdir(parents=True, exist_ok=True)
395
+ continue
396
+ target.parent.mkdir(parents=True, exist_ok=True)
397
+ target.write_bytes(archive.read(member))
398
+ mode = stat.S_IMODE(member.external_attr >> 16) or 0o644
399
+ target.chmod(mode)
400
+ return destination / PLUGIN_NAME
401
+
402
+
403
+ def _load_json(path: Path, label: str, errors: list[str]) -> dict[str, Any] | None:
404
+ if path.is_symlink() or not path.is_file():
405
+ errors.append(f"missing regular {label}")
406
+ return None
407
+ try:
408
+ value = json.loads(path.read_text(encoding="utf-8"))
409
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
410
+ errors.append(f"invalid {label}: {error}")
411
+ return None
412
+ if not isinstance(value, dict):
413
+ errors.append(f"{label} must contain an object")
414
+ return None
415
+ return value
416
+
417
+
418
+ def validate_plugin(plugin_dir: Path) -> list[str]:
419
+ """Return structural, schema, safety, and self-containment errors."""
420
+ errors: list[str] = []
421
+ if plugin_dir.is_symlink() or not plugin_dir.is_dir():
422
+ return ["plugin root must be a regular directory"]
423
+ for path in sorted(plugin_dir.rglob("*")):
424
+ relative = path.relative_to(plugin_dir)
425
+ if path.is_symlink():
426
+ errors.append(f"plugin contains a symlink: {relative}")
427
+ continue
428
+ mode = stat.S_IMODE(path.stat().st_mode)
429
+ if mode & 0o022:
430
+ errors.append(f"plugin contains unsafe mode: {relative}")
431
+ if not path.is_dir() and not path.is_file():
432
+ errors.append(f"plugin contains a special entry: {relative}")
433
+ _validate_canonical_directory(plugin_dir, errors)
434
+
435
+ manifest = _load_json(plugin_dir / "plugin.json", "plugin.json", errors)
436
+ expected_manifest = json.loads(render_manifest())
437
+ if manifest is not None and manifest != expected_manifest:
438
+ errors.append("plugin.json differs from the exact official schema")
439
+
440
+ hooks = _load_json(plugin_dir / "hooks.json", "hooks.json", errors)
441
+ expected_hooks = build_document(PLUGIN_HOOK_COMMAND)
442
+ if hooks is not None:
443
+ try:
444
+ validate_document(hooks, PLUGIN_HOOK_COMMAND)
445
+ except ValueError as error:
446
+ errors.append(str(error))
447
+ if hooks != expected_hooks:
448
+ errors.append("plugin hooks differ from the canonical definition")
449
+
450
+ required = {
451
+ "runtime": f"runtime/{RUNTIME_NAME}",
452
+ "LICENSE": "LICENSE",
453
+ "persona resource": "resources/personas/backend-lead.md",
454
+ "briefing runtime": "skills/briefing/scripts/session_token_stats.py",
455
+ "skill audit runtime": "scripts/audit_skills.py",
456
+ }
457
+ for label, relative in required.items():
458
+ path = plugin_dir / relative
459
+ if path.is_symlink() or not path.is_file():
460
+ errors.append(f"plugin {label} is missing")
461
+ runtime = plugin_dir / "runtime" / RUNTIME_NAME
462
+ if runtime.is_file() and not runtime.stat().st_mode & stat.S_IXUSR:
463
+ errors.append("plugin hook runtime must be executable")
464
+
465
+ for directory, required_file in (
466
+ ("rules", None),
467
+ ("skills", "SKILL.md"),
468
+ ("agents", "agent.md"),
469
+ ):
470
+ root = plugin_dir / directory
471
+ if root.is_symlink() or not root.is_dir():
472
+ errors.append(f"plugin {directory} directory is missing")
473
+ continue
474
+ files = list(root.rglob(required_file or "*.md"))
475
+ if not files:
476
+ errors.append(f"plugin {directory} directory is empty")
477
+
478
+ for path in sorted(plugin_dir.rglob("*")):
479
+ if not path.is_file() or path.is_symlink():
480
+ continue
481
+ try:
482
+ text = path.read_text(encoding="utf-8")
483
+ except UnicodeDecodeError:
484
+ continue
485
+ if ABSOLUTE_HOST_PATH.search(text):
486
+ errors.append(
487
+ "plugin contains an absolute host path: "
488
+ f"{path.relative_to(plugin_dir)}"
489
+ )
490
+ if str(TOOLKIT_DIR) in text:
491
+ errors.append(
492
+ "plugin contains a source-root reference: "
493
+ f"{path.relative_to(plugin_dir)}"
494
+ )
495
+ if "${CLAUDE_SKILL_DIR}" in text:
496
+ errors.append(
497
+ "plugin contains a Claude-only skill path: "
498
+ f"{path.relative_to(plugin_dir)}"
499
+ )
500
+ return errors
501
+
502
+
503
+ def verify(source: str | os.PathLike[str]) -> bool:
504
+ path = lexical_absolute(source)
505
+ if path.is_symlink():
506
+ raise ValueError(f"refusing symlinked plugin input: {path}")
507
+ if path.is_dir():
508
+ errors = validate_plugin(path)
509
+ elif path.is_file():
510
+ with zipfile.ZipFile(path) as archive:
511
+ errors = _validate_canonical_archive(archive)
512
+ with tempfile.TemporaryDirectory(prefix="ai-toolkit-antigravity-verify-") as tmp:
513
+ staged = _extract_archive(path, Path(tmp))
514
+ errors.extend(validate_plugin(staged))
515
+ else:
516
+ raise FileNotFoundError(path)
517
+ if errors:
518
+ raise ValueError("; ".join(errors))
519
+ return True
520
+
521
+
522
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
523
+ parser = argparse.ArgumentParser(
524
+ description=__doc__,
525
+ epilog=(
526
+ "Install locations:\n"
527
+ " .agents/plugins/ai-toolkit/ (workspace)\n"
528
+ " ~/.gemini/antigravity-cli/plugins/ai-toolkit/ (CLI)\n"
529
+ " ~/.gemini/config/plugins/ai-toolkit/ (IDE/shared product)"
530
+ ),
531
+ formatter_class=argparse.RawDescriptionHelpFormatter,
532
+ )
533
+ subparsers = parser.add_subparsers(dest="action", required=True)
534
+ export_parser = subparsers.add_parser("export", help="build plugin ZIP")
535
+ export_parser.add_argument("output_path", nargs="?")
536
+ export_parser.add_argument(
537
+ "--output", dest="output_option", default=None
538
+ )
539
+ verify_parser = subparsers.add_parser("verify", help="verify archive or directory")
540
+ verify_parser.add_argument("source")
541
+ return parser.parse_args(argv)
542
+
543
+
544
+ def main(argv: list[str] | None = None) -> int:
545
+ args = parse_args(argv)
546
+ try:
547
+ if args.action == "export":
548
+ output = (
549
+ args.output_option
550
+ or args.output_path
551
+ or "ai-toolkit-antigravity-plugin.zip"
552
+ )
553
+ path = export(output)
554
+ print(f"Created: {path}")
555
+ print(
556
+ "Install under .agents/plugins/ai-toolkit/ (workspace), "
557
+ "~/.gemini/antigravity-cli/plugins/ai-toolkit/ (CLI), or "
558
+ "~/.gemini/config/plugins/ai-toolkit/ (IDE/shared product)."
559
+ )
560
+ else:
561
+ verify(args.source)
562
+ print("Antigravity plugin validation passed")
563
+ return 0
564
+ except (OSError, ValueError, RuntimeError, zipfile.BadZipFile) as error:
565
+ print(f"ERROR: {error}", file=sys.stderr)
566
+ return 1
567
+
568
+
569
+ if __name__ == "__main__":
570
+ raise SystemExit(main())