@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
@@ -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())