@softspark/ai-toolkit 4.24.0 → 4.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +35 -15
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/skills/hook-creator/SKILL.md +18 -4
- package/app/surface.json +4 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +67 -19
- package/bin/ai-toolkit.js +27 -3
- package/kb/reference/architecture-overview.md +13 -5
- package/kb/reference/claude-ecosystem-expansion-foundations.md +30 -5
- package/kb/reference/cli-reference.md +27 -2
- package/kb/reference/codex-cli-compatibility.md +101 -11
- package/kb/reference/global-install-model.md +10 -8
- package/kb/reference/hooks-catalog.md +41 -5
- package/kb/reference/mcp-editor-compatibility.md +3 -3
- package/kb/reference/mcp-templates.md +3 -3
- package/kb/reference/opencode-compatibility.md +53 -5
- package/kb/reference/supported-tools-registry.md +31 -26
- package/llms-full.txt +312 -73
- package/manifest.json +1 -1
- package/package.json +6 -2
- package/scripts/antigravity_plugin.py +570 -0
- package/scripts/codex_plugin.py +764 -0
- package/scripts/ecosystem_tools.json +92 -17
- package/scripts/generate_antigravity.py +16 -14
- package/scripts/generate_antigravity_agents.py +255 -0
- package/scripts/generate_antigravity_hooks.py +344 -0
- package/scripts/generate_cline_hooks.py +391 -0
- package/scripts/generate_cline_rules.py +210 -43
- package/scripts/generate_cline_skills.py +65 -2
- package/scripts/generate_codex_hooks.py +70 -8
- package/scripts/generate_gemini_agents.py +197 -0
- package/scripts/generate_gemini_hooks.py +24 -4
- package/scripts/generate_opencode_skills.py +544 -0
- package/scripts/inject_hook_cli.py +4 -26
- package/scripts/install.py +11 -10
- package/scripts/install_steps/ai_tools.py +209 -34
- package/scripts/mcp_editors.py +9 -1
- package/scripts/plugin.py +21 -0
- package/scripts/plugin_schema.py +8 -7
- package/scripts/secure_fs.py +35 -0
- package/scripts/uninstall.py +162 -18
- package/scripts/validate.py +42 -12
|
@@ -0,0 +1,764 @@
|
|
|
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
|
+
"""Build and verify the native ai-toolkit plugin for Codex.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
codex_plugin.py export [--output FILE]
|
|
10
|
+
codex_plugin.py verify
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import zipfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
|
|
28
|
+
from codex_skill_adapter import build_codex_skill_text
|
|
29
|
+
from generate_codex_hooks import (
|
|
30
|
+
CODEX_HOOKS,
|
|
31
|
+
_asset_names,
|
|
32
|
+
_managed_asset_content,
|
|
33
|
+
validate_hooks_document,
|
|
34
|
+
)
|
|
35
|
+
from secure_fs import SecureDestination, lexical_absolute, run_secure_transaction
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
TOOLKIT_DIR = Path(__file__).resolve().parent.parent
|
|
39
|
+
APP_DIR = TOOLKIT_DIR / "app"
|
|
40
|
+
PLUGIN_NAME = "ai-toolkit"
|
|
41
|
+
FIXED_ZIP_TIME = (2026, 1, 1, 0, 0, 0)
|
|
42
|
+
SKIP_NAMES = frozenset({"__pycache__", ".DS_Store"})
|
|
43
|
+
_TASK_ONLY_FRONTMATTER_RE = re.compile(
|
|
44
|
+
r"^(?:disable-model-invocation|disable_model_invocation):\s*true\s*$\n?",
|
|
45
|
+
re.MULTILINE,
|
|
46
|
+
)
|
|
47
|
+
_SEMVER_RE = re.compile(
|
|
48
|
+
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
49
|
+
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
|
|
50
|
+
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
|
51
|
+
)
|
|
52
|
+
_PLUGIN_ASSET_RE = re.compile(r"\$\{PLUGIN_ROOT\}/hooks/([A-Za-z0-9._-]+)")
|
|
53
|
+
_BARE_SCRIPT_REFERENCE_RE = re.compile(
|
|
54
|
+
r"(?<![./A-Za-z0-9_-])"
|
|
55
|
+
r"(scripts/(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+)"
|
|
56
|
+
)
|
|
57
|
+
_SKILL_TEXT_REWRITES = {
|
|
58
|
+
"briefing": (
|
|
59
|
+
("scripts/session_token_stats.py", "./scripts/session_token_stats.py"),
|
|
60
|
+
(
|
|
61
|
+
"app/hooks/ai-toolkit-statusline.sh",
|
|
62
|
+
"the core-install statusline hook",
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
"docs": (
|
|
66
|
+
(
|
|
67
|
+
"app/skills/documentation-standards/SKILL.md",
|
|
68
|
+
"../documentation-standards/SKILL.md",
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
"persona": (
|
|
72
|
+
(
|
|
73
|
+
"If file not found, try the installed location: "
|
|
74
|
+
"`~/.claude/skills/persona/../../../app/personas/{name}.md`",
|
|
75
|
+
"If the file is missing, report the available filenames from "
|
|
76
|
+
"`./personas/`; do not search global paths",
|
|
77
|
+
),
|
|
78
|
+
(
|
|
79
|
+
"relative to the toolkit root. When the toolkit is globally "
|
|
80
|
+
"installed, that root is at "
|
|
81
|
+
"`~/.claude/skills/persona/../../../app/personas/` — fallback "
|
|
82
|
+
"paths matter.",
|
|
83
|
+
"relative to this installed skill directory.",
|
|
84
|
+
),
|
|
85
|
+
(
|
|
86
|
+
"(relative to toolkit root)",
|
|
87
|
+
"(relative to this installed skill directory)",
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
"~/.claude/skills/persona/../../../app/personas/",
|
|
91
|
+
"./personas/",
|
|
92
|
+
),
|
|
93
|
+
("app/personas/", "./personas/"),
|
|
94
|
+
),
|
|
95
|
+
"skill-audit": (
|
|
96
|
+
("scripts/audit_skills.py", "../../scripts/audit_skills.py"),
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
_SKILL_RESOURCE_SOURCES = {
|
|
100
|
+
"briefing": (
|
|
101
|
+
(
|
|
102
|
+
TOOLKIT_DIR / "scripts" / "session_token_stats.py",
|
|
103
|
+
Path("scripts/session_token_stats.py"),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
"persona": ((APP_DIR / "personas", Path("personas")),),
|
|
107
|
+
}
|
|
108
|
+
_PLUGIN_SCRIPT_NAMES = (
|
|
109
|
+
"_common.py",
|
|
110
|
+
"audit_skills.py",
|
|
111
|
+
"emission.py",
|
|
112
|
+
"frontmatter.py",
|
|
113
|
+
"injection.py",
|
|
114
|
+
"instruction_core.py",
|
|
115
|
+
)
|
|
116
|
+
# Bare paths have different ownership semantics. Plugin runtime helpers must be
|
|
117
|
+
# rewritten and bundled. The other categories intentionally describe either a
|
|
118
|
+
# target repository or the source toolkit and must remain visible to the model.
|
|
119
|
+
_BARE_SCRIPT_CLASSIFICATIONS = {
|
|
120
|
+
("a11y-validate", "scripts/a11y-scanner.py"): "skill-local-informational",
|
|
121
|
+
("agent-creator", "scripts/validate.py"): "source-toolkit-operation",
|
|
122
|
+
("briefing", "scripts/session_token_stats.py"): "plugin-runtime",
|
|
123
|
+
(
|
|
124
|
+
"documentation-standards",
|
|
125
|
+
"scripts/validate.py",
|
|
126
|
+
): "source-toolkit-informational",
|
|
127
|
+
(
|
|
128
|
+
"documentation-standards",
|
|
129
|
+
"scripts/validate_kb_frontmatter.py",
|
|
130
|
+
): "target-workspace-informational",
|
|
131
|
+
("evaluate", "scripts/evaluate_rag.py"): "target-workspace-operation",
|
|
132
|
+
("evaluate", "scripts/evaluate_skills.py"): "source-toolkit-informational",
|
|
133
|
+
("evaluate", "scripts/golden_dataset.json"): "target-workspace-resource",
|
|
134
|
+
("evaluate", "scripts/knowledge_gaps.py"): "target-workspace-operation",
|
|
135
|
+
("evolve", "scripts/audit_skills.py"): "source-toolkit-informational",
|
|
136
|
+
("evolve", "scripts/evaluate_skills.py"): "source-toolkit-informational",
|
|
137
|
+
("evolve", "scripts/validate.py"): "source-toolkit-operation",
|
|
138
|
+
("hipaa-validate", "scripts/hipaa_scan.py"): "skill-local-informational",
|
|
139
|
+
(
|
|
140
|
+
"hook-creator",
|
|
141
|
+
"scripts/install_git_hooks.py",
|
|
142
|
+
): "source-toolkit-informational",
|
|
143
|
+
("hook-creator", "scripts/validate.py"): "source-toolkit-operation",
|
|
144
|
+
("plugin-creator", "scripts/validate.py"): "source-toolkit-operation",
|
|
145
|
+
("rag-patterns", "scripts/evaluate_rag.py"): "target-workspace-operation",
|
|
146
|
+
(
|
|
147
|
+
"rag-patterns",
|
|
148
|
+
"scripts/knowledge_gaps.py",
|
|
149
|
+
): "target-workspace-operation",
|
|
150
|
+
("seo-validate", "scripts/seo-scanner.py"): "skill-local-informational",
|
|
151
|
+
("skill-audit", "scripts/audit_skills.py"): "plugin-runtime",
|
|
152
|
+
}
|
|
153
|
+
# Full-catalog audit: source-workspace paths in authoring/audit skills operate on
|
|
154
|
+
# the user's current checkout. These are the package-owned cross-resource paths
|
|
155
|
+
# that must resolve inside the exported plugin itself.
|
|
156
|
+
_SKILL_RUNTIME_REFERENCES = {
|
|
157
|
+
"briefing": ("scripts/session_token_stats.py",),
|
|
158
|
+
"docs": ("../documentation-standards/SKILL.md",),
|
|
159
|
+
"persona": (
|
|
160
|
+
"personas/backend-lead.md",
|
|
161
|
+
"personas/devops-eng.md",
|
|
162
|
+
"personas/frontend-lead.md",
|
|
163
|
+
"personas/junior-dev.md",
|
|
164
|
+
),
|
|
165
|
+
"skill-audit": tuple(
|
|
166
|
+
f"../../scripts/{name}" for name in _PLUGIN_SCRIPT_NAMES
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _package_metadata() -> dict[str, Any]:
|
|
172
|
+
payload = json.loads((TOOLKIT_DIR / "package.json").read_text(encoding="utf-8"))
|
|
173
|
+
if not isinstance(payload, dict):
|
|
174
|
+
raise ValueError("package.json must contain an object")
|
|
175
|
+
return payload
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _repository_url(package: dict[str, Any]) -> str:
|
|
179
|
+
repository = package.get("repository")
|
|
180
|
+
if isinstance(repository, dict):
|
|
181
|
+
repository = repository.get("url")
|
|
182
|
+
if not isinstance(repository, str) or not repository:
|
|
183
|
+
raise ValueError("package.json repository URL is required")
|
|
184
|
+
return repository.removeprefix("git+").removesuffix(".git")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def render_manifest() -> str:
|
|
188
|
+
"""Render stable plugin metadata from package.json."""
|
|
189
|
+
package = _package_metadata()
|
|
190
|
+
homepage = package.get("homepage")
|
|
191
|
+
version = package.get("version")
|
|
192
|
+
license_name = package.get("license")
|
|
193
|
+
if not isinstance(homepage, str) or not homepage.startswith("https://"):
|
|
194
|
+
raise ValueError("package.json homepage must be an HTTPS URL")
|
|
195
|
+
if not isinstance(version, str) or not version:
|
|
196
|
+
raise ValueError("package.json version is required")
|
|
197
|
+
if not isinstance(license_name, str) or not license_name:
|
|
198
|
+
raise ValueError("package.json license is required")
|
|
199
|
+
|
|
200
|
+
manifest = {
|
|
201
|
+
"name": PLUGIN_NAME,
|
|
202
|
+
"version": version,
|
|
203
|
+
"description": (
|
|
204
|
+
"AI engineering skills and lifecycle guardrails for Codex CLI."
|
|
205
|
+
),
|
|
206
|
+
"author": {
|
|
207
|
+
"name": "SoftSpark",
|
|
208
|
+
"email": "biuro@softspark.eu",
|
|
209
|
+
"url": "https://github.com/softspark",
|
|
210
|
+
},
|
|
211
|
+
"homepage": homepage,
|
|
212
|
+
"repository": _repository_url(package),
|
|
213
|
+
"license": license_name,
|
|
214
|
+
"keywords": [
|
|
215
|
+
"ai-toolkit",
|
|
216
|
+
"codex",
|
|
217
|
+
"developer-tools",
|
|
218
|
+
"skills",
|
|
219
|
+
"hooks",
|
|
220
|
+
],
|
|
221
|
+
"skills": "./skills/",
|
|
222
|
+
"interface": {
|
|
223
|
+
"displayName": "AI Toolkit",
|
|
224
|
+
"shortDescription": "Engineering skills and lifecycle guardrails",
|
|
225
|
+
"longDescription": (
|
|
226
|
+
"Use AI Toolkit workflows for implementation, testing, review, "
|
|
227
|
+
"security, architecture, and delivery in Codex CLI."
|
|
228
|
+
),
|
|
229
|
+
"developerName": "SoftSpark",
|
|
230
|
+
"category": "Developer Tools",
|
|
231
|
+
"capabilities": ["Skills", "Lifecycle hooks", "Developer workflows"],
|
|
232
|
+
"websiteURL": homepage,
|
|
233
|
+
"defaultPrompt": [
|
|
234
|
+
"Use AI Toolkit to implement this change with tests.",
|
|
235
|
+
"Review this code with the relevant AI Toolkit skills.",
|
|
236
|
+
"Verify this repository before claiming the task is complete.",
|
|
237
|
+
],
|
|
238
|
+
"brandColor": "#2563EB",
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
return json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _bare_script_references(text: str) -> set[str]:
|
|
245
|
+
return set(_BARE_SCRIPT_REFERENCE_RE.findall(text))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _assert_bare_script_references_classified(skill_name: str, text: str) -> None:
|
|
249
|
+
unknown = sorted(
|
|
250
|
+
reference
|
|
251
|
+
for reference in _bare_script_references(text)
|
|
252
|
+
if (skill_name, reference) not in _BARE_SCRIPT_CLASSIFICATIONS
|
|
253
|
+
)
|
|
254
|
+
if unknown:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"unclassified bare script references in {skill_name}: {unknown}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _plugin_skill_text(source: Path) -> str:
|
|
261
|
+
"""Render a Codex-adapted skill accepted by plugin ingestion."""
|
|
262
|
+
rendered = build_codex_skill_text(source)
|
|
263
|
+
_assert_bare_script_references_classified(source.parent.name, rendered)
|
|
264
|
+
rendered = _TASK_ONLY_FRONTMATTER_RE.sub("", rendered)
|
|
265
|
+
for source_path, plugin_path in _SKILL_TEXT_REWRITES.get(
|
|
266
|
+
source.parent.name,
|
|
267
|
+
(),
|
|
268
|
+
):
|
|
269
|
+
rendered = rendered.replace(source_path, plugin_path)
|
|
270
|
+
return rendered
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _plugin_audit_helper_content(source: Path) -> str:
|
|
274
|
+
"""Adapt the source audit helper to the native plugin skills layout."""
|
|
275
|
+
text = source.read_text(encoding="utf-8")
|
|
276
|
+
import_marker = "from frontmatter import frontmatter_field\n"
|
|
277
|
+
helper = (
|
|
278
|
+
"\n\ndef _skills_dir(toolkit_root: Path) -> Path:\n"
|
|
279
|
+
" plugin_skills = toolkit_root / \"skills\"\n"
|
|
280
|
+
" if (toolkit_root / \".codex-plugin\").is_dir():\n"
|
|
281
|
+
" return plugin_skills\n"
|
|
282
|
+
" return toolkit_root / \"app\" / \"skills\"\n"
|
|
283
|
+
)
|
|
284
|
+
replacements = {
|
|
285
|
+
"skills = toolkit_root / \"app\" / \"skills\"": (
|
|
286
|
+
"skills = _skills_dir(toolkit_root)"
|
|
287
|
+
),
|
|
288
|
+
"skills = app / \"skills\"": "skills = _skills_dir(toolkit_root)",
|
|
289
|
+
}
|
|
290
|
+
if text.count(import_marker) != 1:
|
|
291
|
+
raise ValueError("audit_skills.py import marker changed")
|
|
292
|
+
text = text.replace(import_marker, import_marker + helper, 1)
|
|
293
|
+
for original, replacement in replacements.items():
|
|
294
|
+
if text.count(original) != 1:
|
|
295
|
+
raise ValueError(f"audit_skills.py layout marker changed: {original}")
|
|
296
|
+
text = text.replace(original, replacement, 1)
|
|
297
|
+
return text
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _stage_plugin_scripts(destination: Path) -> None:
|
|
301
|
+
destination.mkdir()
|
|
302
|
+
for name in _PLUGIN_SCRIPT_NAMES:
|
|
303
|
+
source = TOOLKIT_DIR / "scripts" / name
|
|
304
|
+
target = destination / name
|
|
305
|
+
_copy_file_strict(source, target)
|
|
306
|
+
if name == "audit_skills.py":
|
|
307
|
+
target.write_text(_plugin_audit_helper_content(source), encoding="utf-8")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _copy_file_strict(source: Path, destination: Path) -> None:
|
|
311
|
+
"""Copy one regular source file without following links."""
|
|
312
|
+
if source.is_symlink() or not source.is_file():
|
|
313
|
+
raise ValueError(f"unsafe source file: {source}")
|
|
314
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
destination.write_bytes(source.read_bytes())
|
|
316
|
+
os.chmod(destination, stat.S_IMODE(source.stat().st_mode))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _copy_tree_strict(source: Path, destination: Path) -> None:
|
|
320
|
+
"""Copy a source tree while rejecting links and non-regular entries."""
|
|
321
|
+
if source.is_symlink() or not source.is_dir():
|
|
322
|
+
raise ValueError(f"unsafe source directory: {source}")
|
|
323
|
+
for path in sorted(source.rglob("*")):
|
|
324
|
+
relative = path.relative_to(source)
|
|
325
|
+
if any(
|
|
326
|
+
part in SKIP_NAMES or part.endswith(".pyc")
|
|
327
|
+
for part in relative.parts
|
|
328
|
+
):
|
|
329
|
+
continue
|
|
330
|
+
if path.is_symlink():
|
|
331
|
+
raise ValueError(f"source symlink is not allowed: {path}")
|
|
332
|
+
target = destination / relative
|
|
333
|
+
if path.is_dir():
|
|
334
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
elif path.is_file():
|
|
336
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
target.write_bytes(path.read_bytes())
|
|
338
|
+
os.chmod(target, stat.S_IMODE(path.stat().st_mode))
|
|
339
|
+
else:
|
|
340
|
+
raise ValueError(f"unsupported source entry: {path}")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _stage_skills(destination: Path) -> None:
|
|
344
|
+
skills_source = APP_DIR / "skills"
|
|
345
|
+
for skill_dir in sorted(skills_source.iterdir()):
|
|
346
|
+
if skill_dir.name.startswith((".", "_")) or not skill_dir.is_dir():
|
|
347
|
+
continue
|
|
348
|
+
skill_file = skill_dir / "SKILL.md"
|
|
349
|
+
if skill_file.is_symlink() or not skill_file.is_file():
|
|
350
|
+
raise ValueError(f"invalid skill source: {skill_file}")
|
|
351
|
+
target = destination / skill_dir.name
|
|
352
|
+
_copy_tree_strict(skill_dir, target)
|
|
353
|
+
(target / "SKILL.md").write_text(
|
|
354
|
+
_plugin_skill_text(skill_file),
|
|
355
|
+
encoding="utf-8",
|
|
356
|
+
)
|
|
357
|
+
for source, relative_target in _SKILL_RESOURCE_SOURCES.get(
|
|
358
|
+
skill_dir.name,
|
|
359
|
+
(),
|
|
360
|
+
):
|
|
361
|
+
destination_path = target / relative_target
|
|
362
|
+
if source.is_dir() and not source.is_symlink():
|
|
363
|
+
_copy_tree_strict(source, destination_path)
|
|
364
|
+
else:
|
|
365
|
+
_copy_file_strict(source, destination_path)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def build_plugin_hooks() -> dict[str, Any]:
|
|
369
|
+
"""Build native command hooks rooted inside the installed plugin."""
|
|
370
|
+
hooks: dict[str, list[dict[str, Any]]] = {}
|
|
371
|
+
for event, entries in CODEX_HOOKS.items():
|
|
372
|
+
groups: list[dict[str, Any]] = []
|
|
373
|
+
for matcher, script in entries:
|
|
374
|
+
handler: dict[str, Any] = {
|
|
375
|
+
"type": "command",
|
|
376
|
+
"command": (
|
|
377
|
+
"AI_TOOLKIT_HOOK_QUIET=1 "
|
|
378
|
+
"AI_TOOLKIT_HOOK_OWNER=ai-toolkit "
|
|
379
|
+
f'"${{PLUGIN_ROOT}}/hooks/{script}"'
|
|
380
|
+
),
|
|
381
|
+
}
|
|
382
|
+
if event == "SessionEnd":
|
|
383
|
+
handler["timeout"] = 3
|
|
384
|
+
group: dict[str, Any] = {"hooks": [handler]}
|
|
385
|
+
if matcher:
|
|
386
|
+
group["matcher"] = matcher
|
|
387
|
+
groups.append(group)
|
|
388
|
+
hooks[event] = groups
|
|
389
|
+
document = {"hooks": hooks}
|
|
390
|
+
validate_hooks_document(document)
|
|
391
|
+
return document
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def stage_plugin(destination: Path) -> None:
|
|
395
|
+
"""Create a self-contained native Codex plugin tree."""
|
|
396
|
+
if destination.is_symlink():
|
|
397
|
+
raise ValueError(f"refusing symlinked staging directory: {destination}")
|
|
398
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
399
|
+
manifest_path = destination / ".codex-plugin" / "plugin.json"
|
|
400
|
+
manifest_path.parent.mkdir(parents=True)
|
|
401
|
+
manifest_path.write_text(render_manifest(), encoding="utf-8")
|
|
402
|
+
|
|
403
|
+
_stage_skills(destination / "skills")
|
|
404
|
+
_stage_plugin_scripts(destination / "scripts")
|
|
405
|
+
|
|
406
|
+
hooks_dir = destination / "hooks"
|
|
407
|
+
hooks_dir.mkdir()
|
|
408
|
+
hooks_document = build_plugin_hooks()
|
|
409
|
+
(hooks_dir / "hooks.json").write_text(
|
|
410
|
+
json.dumps(hooks_document, indent=2, ensure_ascii=False) + "\n",
|
|
411
|
+
encoding="utf-8",
|
|
412
|
+
)
|
|
413
|
+
for name in sorted(_asset_names()):
|
|
414
|
+
asset = hooks_dir / name
|
|
415
|
+
asset.write_bytes(_managed_asset_content(name))
|
|
416
|
+
os.chmod(asset, 0o755)
|
|
417
|
+
|
|
418
|
+
license_source = TOOLKIT_DIR / "LICENSE"
|
|
419
|
+
if license_source.is_symlink() or not license_source.is_file():
|
|
420
|
+
raise ValueError("LICENSE must be a regular source file")
|
|
421
|
+
(destination / "LICENSE").write_bytes(license_source.read_bytes())
|
|
422
|
+
_copy_file_strict(
|
|
423
|
+
APP_DIR / "constitution.md",
|
|
424
|
+
destination / "constitution.md",
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _load_json_object(path: Path, label: str, errors: list[str]) -> dict[str, Any] | None:
|
|
429
|
+
if path.is_symlink() or not path.is_file():
|
|
430
|
+
errors.append(f"missing regular {label}")
|
|
431
|
+
return None
|
|
432
|
+
try:
|
|
433
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
434
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
435
|
+
errors.append(f"invalid {label}: {error}")
|
|
436
|
+
return None
|
|
437
|
+
if not isinstance(payload, dict):
|
|
438
|
+
errors.append(f"{label} must contain an object")
|
|
439
|
+
return None
|
|
440
|
+
return payload
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _validate_manifest(plugin_dir: Path, errors: list[str]) -> None:
|
|
444
|
+
manifest_path = plugin_dir / ".codex-plugin" / "plugin.json"
|
|
445
|
+
manifest = _load_json_object(manifest_path, "plugin manifest", errors)
|
|
446
|
+
if manifest is None:
|
|
447
|
+
return
|
|
448
|
+
extra_manifest_files = [
|
|
449
|
+
path
|
|
450
|
+
for path in (plugin_dir / ".codex-plugin").iterdir()
|
|
451
|
+
if path.name != "plugin.json"
|
|
452
|
+
]
|
|
453
|
+
if extra_manifest_files:
|
|
454
|
+
errors.append("only plugin.json may appear in .codex-plugin")
|
|
455
|
+
|
|
456
|
+
allowed = {
|
|
457
|
+
"name",
|
|
458
|
+
"version",
|
|
459
|
+
"description",
|
|
460
|
+
"author",
|
|
461
|
+
"homepage",
|
|
462
|
+
"repository",
|
|
463
|
+
"license",
|
|
464
|
+
"keywords",
|
|
465
|
+
"skills",
|
|
466
|
+
"interface",
|
|
467
|
+
}
|
|
468
|
+
unknown = sorted(set(manifest) - allowed)
|
|
469
|
+
if unknown:
|
|
470
|
+
errors.append(f"unsupported plugin manifest fields: {unknown}")
|
|
471
|
+
if "hooks" in manifest:
|
|
472
|
+
errors.append("plugin manifest must rely on default hooks/hooks.json discovery")
|
|
473
|
+
if manifest.get("name") != PLUGIN_NAME:
|
|
474
|
+
errors.append(f"plugin manifest name must be {PLUGIN_NAME}")
|
|
475
|
+
version = manifest.get("version")
|
|
476
|
+
if not isinstance(version, str) or _SEMVER_RE.fullmatch(version) is None:
|
|
477
|
+
errors.append("plugin manifest version must be strict semver")
|
|
478
|
+
if manifest.get("skills") != "./skills/":
|
|
479
|
+
errors.append("plugin manifest skills must be ./skills/")
|
|
480
|
+
if not (plugin_dir / "skills").is_dir():
|
|
481
|
+
errors.append("plugin skills directory is missing")
|
|
482
|
+
for field in ("description", "homepage", "repository", "license"):
|
|
483
|
+
if not isinstance(manifest.get(field), str) or not manifest[field].strip():
|
|
484
|
+
errors.append(f"plugin manifest {field} must be a non-empty string")
|
|
485
|
+
author = manifest.get("author")
|
|
486
|
+
if not isinstance(author, dict) or not isinstance(author.get("name"), str):
|
|
487
|
+
errors.append("plugin manifest author.name is required")
|
|
488
|
+
interface = manifest.get("interface")
|
|
489
|
+
required_interface = {
|
|
490
|
+
"displayName",
|
|
491
|
+
"shortDescription",
|
|
492
|
+
"longDescription",
|
|
493
|
+
"developerName",
|
|
494
|
+
"category",
|
|
495
|
+
"capabilities",
|
|
496
|
+
"websiteURL",
|
|
497
|
+
"defaultPrompt",
|
|
498
|
+
}
|
|
499
|
+
if not isinstance(interface, dict):
|
|
500
|
+
errors.append("plugin manifest interface must be an object")
|
|
501
|
+
else:
|
|
502
|
+
missing = sorted(required_interface - set(interface))
|
|
503
|
+
if missing:
|
|
504
|
+
errors.append(f"plugin manifest interface fields are missing: {missing}")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _validate_skills(plugin_dir: Path, errors: list[str]) -> None:
|
|
508
|
+
skills_dir = plugin_dir / "skills"
|
|
509
|
+
if skills_dir.is_symlink() or not skills_dir.is_dir():
|
|
510
|
+
errors.append("plugin skills must be a regular directory")
|
|
511
|
+
return
|
|
512
|
+
skills = sorted(path for path in skills_dir.iterdir() if path.is_dir())
|
|
513
|
+
if not skills:
|
|
514
|
+
errors.append("plugin must contain at least one skill")
|
|
515
|
+
for skill in skills:
|
|
516
|
+
skill_file = skill / "SKILL.md"
|
|
517
|
+
if skill.is_symlink() or skill_file.is_symlink() or not skill_file.is_file():
|
|
518
|
+
errors.append(f"invalid plugin skill: {skill.name}")
|
|
519
|
+
continue
|
|
520
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
521
|
+
if not text.startswith("---\n") or "\nname:" not in text or "\ndescription:" not in text:
|
|
522
|
+
errors.append(f"invalid plugin skill frontmatter: {skill.name}")
|
|
523
|
+
if _TASK_ONLY_FRONTMATTER_RE.search(text):
|
|
524
|
+
errors.append(f"plugin skill disables model invocation: {skill.name}")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _validate_skill_runtime_dependencies(
|
|
528
|
+
plugin_dir: Path,
|
|
529
|
+
errors: list[str],
|
|
530
|
+
) -> None:
|
|
531
|
+
"""Check every declared cross-resource dependency in bundled skills."""
|
|
532
|
+
for skill_name, relative_paths in _SKILL_RUNTIME_REFERENCES.items():
|
|
533
|
+
skill_dir = plugin_dir / "skills" / skill_name
|
|
534
|
+
for relative_path in relative_paths:
|
|
535
|
+
resource = skill_dir / relative_path
|
|
536
|
+
if resource.is_symlink() or not resource.is_file():
|
|
537
|
+
errors.append(
|
|
538
|
+
f"{skill_name} runtime resource is missing: {relative_path}"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
skill_file = skill_dir / "SKILL.md"
|
|
542
|
+
if skill_file.is_symlink() or not skill_file.is_file():
|
|
543
|
+
continue
|
|
544
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
545
|
+
for source_path, _plugin_path in _SKILL_TEXT_REWRITES[skill_name]:
|
|
546
|
+
source_reference = re.compile(
|
|
547
|
+
rf"(?<![./A-Za-z0-9_-]){re.escape(source_path)}"
|
|
548
|
+
)
|
|
549
|
+
if source_reference.search(text):
|
|
550
|
+
errors.append(
|
|
551
|
+
f"{skill_name} contains a source-root runtime reference: "
|
|
552
|
+
f"{source_path}"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _validate_bare_script_references(
|
|
557
|
+
plugin_dir: Path,
|
|
558
|
+
errors: list[str],
|
|
559
|
+
) -> None:
|
|
560
|
+
for skill_file in sorted((plugin_dir / "skills").glob("*/SKILL.md")):
|
|
561
|
+
skill_name = skill_file.parent.name
|
|
562
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
563
|
+
for reference in sorted(_bare_script_references(text)):
|
|
564
|
+
classification = _BARE_SCRIPT_CLASSIFICATIONS.get(
|
|
565
|
+
(skill_name, reference)
|
|
566
|
+
)
|
|
567
|
+
if classification is None:
|
|
568
|
+
errors.append(
|
|
569
|
+
f"{skill_name} has an unclassified bare script reference: "
|
|
570
|
+
f"{reference}"
|
|
571
|
+
)
|
|
572
|
+
elif classification == "plugin-runtime":
|
|
573
|
+
errors.append(
|
|
574
|
+
f"{skill_name} plugin runtime script was not rewritten: "
|
|
575
|
+
f"{reference}"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _validate_hooks(plugin_dir: Path, errors: list[str]) -> None:
|
|
580
|
+
hooks_path = plugin_dir / "hooks" / "hooks.json"
|
|
581
|
+
hooks = _load_json_object(hooks_path, "hooks/hooks.json", errors)
|
|
582
|
+
if hooks is None:
|
|
583
|
+
return
|
|
584
|
+
try:
|
|
585
|
+
validate_hooks_document(hooks)
|
|
586
|
+
except ValueError as error:
|
|
587
|
+
errors.append(str(error))
|
|
588
|
+
return
|
|
589
|
+
if hooks != build_plugin_hooks():
|
|
590
|
+
errors.append("plugin hooks differ from the canonical ai-toolkit definition")
|
|
591
|
+
expected_events = set(CODEX_HOOKS)
|
|
592
|
+
actual_events = set(hooks.get("hooks", {}))
|
|
593
|
+
if actual_events != expected_events:
|
|
594
|
+
errors.append(
|
|
595
|
+
f"plugin hook events differ from the wired Codex events: {sorted(actual_events)}"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
referenced: set[str] = set()
|
|
599
|
+
for event, groups in hooks.get("hooks", {}).items():
|
|
600
|
+
for group in groups:
|
|
601
|
+
for handler in group["hooks"]:
|
|
602
|
+
command = handler["command"]
|
|
603
|
+
if any(
|
|
604
|
+
forbidden in command
|
|
605
|
+
for forbidden in (
|
|
606
|
+
"git rev-parse",
|
|
607
|
+
"CODEX_HOME",
|
|
608
|
+
".softspark/ai-toolkit/hooks",
|
|
609
|
+
)
|
|
610
|
+
):
|
|
611
|
+
errors.append(f"plugin {event} command is not self-contained")
|
|
612
|
+
match = _PLUGIN_ASSET_RE.search(command)
|
|
613
|
+
if match is None:
|
|
614
|
+
errors.append(f"plugin {event} command must use PLUGIN_ROOT")
|
|
615
|
+
continue
|
|
616
|
+
referenced.add(match.group(1))
|
|
617
|
+
if event == "SessionEnd" and handler.get("timeout", 0) > 3:
|
|
618
|
+
errors.append("plugin SessionEnd timeout cannot exceed 3 seconds")
|
|
619
|
+
|
|
620
|
+
hooks_dir = plugin_dir / "hooks"
|
|
621
|
+
for name in sorted(_asset_names() | referenced):
|
|
622
|
+
asset = hooks_dir / name
|
|
623
|
+
if asset.is_symlink() or not asset.is_file():
|
|
624
|
+
errors.append(f"missing regular plugin hook asset: {name}")
|
|
625
|
+
elif not asset.stat().st_mode & stat.S_IXUSR:
|
|
626
|
+
errors.append(f"plugin hook asset is not executable: {name}")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def validate_staged_plugin(plugin_dir: Path) -> list[str]:
|
|
630
|
+
"""Validate plugin structure, schema, paths, and self-containment."""
|
|
631
|
+
errors: list[str] = []
|
|
632
|
+
if plugin_dir.is_symlink() or not plugin_dir.is_dir():
|
|
633
|
+
return ["plugin root must be a regular directory"]
|
|
634
|
+
for path in plugin_dir.rglob("*"):
|
|
635
|
+
if path.is_symlink():
|
|
636
|
+
errors.append(f"plugin contains a symlink: {path.relative_to(plugin_dir)}")
|
|
637
|
+
_validate_manifest(plugin_dir, errors)
|
|
638
|
+
_validate_skills(plugin_dir, errors)
|
|
639
|
+
_validate_skill_runtime_dependencies(plugin_dir, errors)
|
|
640
|
+
_validate_bare_script_references(plugin_dir, errors)
|
|
641
|
+
_validate_hooks(plugin_dir, errors)
|
|
642
|
+
license_path = plugin_dir / "LICENSE"
|
|
643
|
+
if license_path.is_symlink() or not license_path.is_file():
|
|
644
|
+
errors.append("plugin LICENSE is missing")
|
|
645
|
+
constitution = plugin_dir / "constitution.md"
|
|
646
|
+
if constitution.is_symlink() or not constitution.is_file():
|
|
647
|
+
errors.append("plugin constitution runtime resource is missing")
|
|
648
|
+
return errors
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def verify_plugin() -> bool:
|
|
652
|
+
with tempfile.TemporaryDirectory(prefix="ai-toolkit-codex-plugin-") as tmp:
|
|
653
|
+
staged = Path(tmp) / PLUGIN_NAME
|
|
654
|
+
stage_plugin(staged)
|
|
655
|
+
errors = validate_staged_plugin(staged)
|
|
656
|
+
for error in errors:
|
|
657
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
658
|
+
if errors:
|
|
659
|
+
return False
|
|
660
|
+
print("Codex plugin validation passed")
|
|
661
|
+
return True
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _archive_file(archive: zipfile.ZipFile, path: Path, arcname: str) -> None:
|
|
665
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
666
|
+
permissions = 0o755 if mode & 0o111 else 0o644
|
|
667
|
+
info = zipfile.ZipInfo(arcname, FIXED_ZIP_TIME)
|
|
668
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
669
|
+
info.external_attr = (stat.S_IFREG | permissions) << 16
|
|
670
|
+
archive.writestr(info, path.read_bytes())
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _absolute_output(output: Path) -> Path:
|
|
674
|
+
return lexical_absolute(output)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _assert_no_symlinked_output_ancestors(output: Path) -> None:
|
|
678
|
+
current = Path(output.anchor)
|
|
679
|
+
for part in output.parts[1:-1]:
|
|
680
|
+
current /= part
|
|
681
|
+
if current.is_symlink():
|
|
682
|
+
raise ValueError(f"refusing symlinked output ancestor: {current}")
|
|
683
|
+
if current.exists() and not current.is_dir():
|
|
684
|
+
raise ValueError(f"refusing non-directory output ancestor: {current}")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _assert_safe_output(output: Path) -> None:
|
|
688
|
+
_assert_no_symlinked_output_ancestors(output)
|
|
689
|
+
if output.exists() and (output.is_symlink() or not output.is_file()):
|
|
690
|
+
raise ValueError(f"refusing unsafe output path: {output}")
|
|
691
|
+
if output.is_symlink():
|
|
692
|
+
raise ValueError(f"refusing symlinked output path: {output}")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _archive_bytes(staged: Path) -> bytes:
|
|
696
|
+
buffer = io.BytesIO()
|
|
697
|
+
with zipfile.ZipFile(buffer, "w") as archive:
|
|
698
|
+
staged_paths = sorted(
|
|
699
|
+
staged.rglob("*"),
|
|
700
|
+
key=lambda path: path.relative_to(staged).as_posix(),
|
|
701
|
+
)
|
|
702
|
+
for path in staged_paths:
|
|
703
|
+
if path.is_symlink():
|
|
704
|
+
raise ValueError(f"staged symlink is not allowed: {path}")
|
|
705
|
+
if path.is_file():
|
|
706
|
+
_archive_file(
|
|
707
|
+
archive,
|
|
708
|
+
path,
|
|
709
|
+
path.relative_to(staged).as_posix(),
|
|
710
|
+
)
|
|
711
|
+
return buffer.getvalue()
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def export_plugin(output: Path) -> bool:
|
|
715
|
+
output = _absolute_output(output)
|
|
716
|
+
_assert_safe_output(output)
|
|
717
|
+
with tempfile.TemporaryDirectory(prefix="ai-toolkit-codex-plugin-") as tmp:
|
|
718
|
+
staged = Path(tmp) / PLUGIN_NAME
|
|
719
|
+
stage_plugin(staged)
|
|
720
|
+
archive_content = _archive_bytes(staged)
|
|
721
|
+
|
|
722
|
+
destination = SecureDestination(
|
|
723
|
+
path=output,
|
|
724
|
+
trusted_root=Path(output.anchor),
|
|
725
|
+
label="Codex plugin archive",
|
|
726
|
+
)
|
|
727
|
+
run_secure_transaction(
|
|
728
|
+
[destination],
|
|
729
|
+
lambda transaction: transaction.atomic_write(
|
|
730
|
+
destination,
|
|
731
|
+
archive_content,
|
|
732
|
+
0o644,
|
|
733
|
+
),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
print(f"Created: {output}")
|
|
737
|
+
print(
|
|
738
|
+
"Next: add the plugin to a local marketplace, install it from /plugins, "
|
|
739
|
+
"then start a new Codex CLI session."
|
|
740
|
+
)
|
|
741
|
+
print("Codex IDE does not support plugins.")
|
|
742
|
+
return True
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
746
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
747
|
+
subparsers = parser.add_subparsers(dest="action", required=True)
|
|
748
|
+
export = subparsers.add_parser("export", help="build a native Codex plugin ZIP")
|
|
749
|
+
export.add_argument("--output", default="ai-toolkit-codex-plugin.zip")
|
|
750
|
+
subparsers.add_parser("verify", help="validate a clean staged Codex plugin")
|
|
751
|
+
return parser.parse_args(argv)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def main(argv: list[str] | None = None) -> int:
|
|
755
|
+
args = parse_args(argv)
|
|
756
|
+
if args.action == "export":
|
|
757
|
+
return 0 if export_plugin(Path(args.output)) else 1
|
|
758
|
+
if args.action == "verify":
|
|
759
|
+
return 0 if verify_plugin() else 1
|
|
760
|
+
return 2
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
if __name__ == "__main__":
|
|
764
|
+
sys.exit(main())
|