@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
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "4.
|
|
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())
|