@softspark/ai-toolkit 4.14.1 → 4.15.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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +11 -10
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/CLAUDE.md.template +3 -0
  5. package/app/hooks/_search-capability.sh +3 -2
  6. package/app/hooks/stop-search-check.sh +2 -1
  7. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  8. package/kb/procedures/maintenance-sop.md +26 -13
  9. package/kb/procedures/release-verification-sop.md +41 -36
  10. package/kb/reference/architecture-overview.md +23 -7
  11. package/kb/reference/codex-cli-compatibility.md +96 -36
  12. package/kb/reference/extension-api.md +52 -9
  13. package/kb/reference/global-install-model.md +53 -21
  14. package/kb/reference/hooks-catalog.md +44 -8
  15. package/kb/reference/mcp-editor-compatibility.md +27 -6
  16. package/kb/reference/mcp-templates.md +12 -6
  17. package/kb/reference/opencode-compatibility.md +13 -7
  18. package/kb/reference/plugin-pack-conventions.md +7 -7
  19. package/kb/reference/skills-catalog.md +3 -3
  20. package/kb/reference/supported-tools-registry.md +19 -17
  21. package/kb/reference/windows-support.md +26 -3
  22. package/llms-full.txt +443 -180
  23. package/llms.txt +1 -1
  24. package/manifest.json +1 -1
  25. package/package.json +2 -2
  26. package/scripts/codex_skill_adapter.py +448 -198
  27. package/scripts/dir_rules_shared.py +2 -11
  28. package/scripts/ecosystem_tools.json +29 -8
  29. package/scripts/emission.py +5 -91
  30. package/scripts/generate_agents_md.py +4 -87
  31. package/scripts/generate_codex.py +5 -95
  32. package/scripts/generate_codex_agents.py +242 -0
  33. package/scripts/generate_codex_hooks.py +648 -55
  34. package/scripts/generate_codex_skills.py +15 -6
  35. package/scripts/generate_copilot.py +771 -74
  36. package/scripts/generate_copilot_hooks.py +606 -0
  37. package/scripts/generate_cursor_hooks.py +453 -121
  38. package/scripts/generate_opencode_commands.py +4 -6
  39. package/scripts/inject_hook_cli.py +770 -205
  40. package/scripts/injection.py +102 -23
  41. package/scripts/install_steps/ai_tools.py +123 -83
  42. package/scripts/instruction_core.py +95 -0
  43. package/scripts/mcp_editors.py +934 -80
  44. package/scripts/mcp_manager.py +46 -26
  45. package/scripts/plugin.py +291 -114
  46. package/scripts/secure_fs.py +538 -0
  47. package/scripts/uninstall.py +1279 -208
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ """Generate native Codex custom-agent TOML files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+ from emission import agents_dir
15
+ from frontmatter import frontmatter_field
16
+
17
+ AGENT_PREFIX = "ai-toolkit-"
18
+ MANAGED_MARKER = "# ai-toolkit-managed: codex-agent"
19
+ REQUIRED_FIELDS = {"name", "description", "developer_instructions"}
20
+
21
+
22
+ def _agent_body(agent_file: Path) -> str:
23
+ text = agent_file.read_text(encoding="utf-8")
24
+ lines = text.splitlines(keepends=True)
25
+ body = text
26
+ if lines and lines[0].rstrip("\r\n") == "---":
27
+ for index, line in enumerate(lines[1:], start=1):
28
+ if line.rstrip("\r\n") == "---":
29
+ body = "".join(lines[index + 1:])
30
+ break
31
+ return body.lstrip("\r\n").rstrip() + "\n"
32
+
33
+
34
+ def _toml_string(value: str) -> str:
35
+ return json.dumps(value, ensure_ascii=False)
36
+
37
+
38
+ def _render_agent(agent_file: Path) -> str:
39
+ name = frontmatter_field(agent_file, "name")
40
+ description = frontmatter_field(agent_file, "description")
41
+ body = _agent_body(agent_file)
42
+ return "\n".join(
43
+ [
44
+ "# Generated by ai-toolkit. Do not edit.",
45
+ MANAGED_MARKER,
46
+ f"name = {_toml_string(name)}",
47
+ f"description = {_toml_string(description)}",
48
+ f"developer_instructions = {_toml_string(body)}",
49
+ "",
50
+ ]
51
+ )
52
+
53
+
54
+ def _is_managed(path: Path) -> bool:
55
+ if path.is_symlink() or not path.is_file():
56
+ return False
57
+ try:
58
+ lines = path.read_text(encoding="utf-8").splitlines()
59
+ except (OSError, UnicodeError):
60
+ return False
61
+ return MANAGED_MARKER in lines[:3]
62
+
63
+
64
+ def _warn_preserved(path: Path, reason: str) -> None:
65
+ print(
66
+ f"Warning: preserving user Codex agent '{path}': {reason}",
67
+ file=sys.stderr,
68
+ )
69
+
70
+
71
+ def _unmanaged_agent_names(output_dir: Path) -> set[str]:
72
+ names: set[str] = set()
73
+ for path in sorted(output_dir.glob("*.toml")):
74
+ if path.is_symlink():
75
+ _warn_preserved(path, "path is a symlink")
76
+ continue
77
+ if _is_managed(path):
78
+ continue
79
+ try:
80
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
81
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
82
+ _warn_preserved(path, f"cannot parse TOML ({error})")
83
+ continue
84
+ name = data.get("name")
85
+ if isinstance(name, str) and name.strip():
86
+ names.add(name)
87
+ return names
88
+
89
+
90
+ def _cleanup_stale(output_dir: Path, expected: set[str]) -> int:
91
+ removed = 0
92
+ for output in sorted(output_dir.glob(f"{AGENT_PREFIX}*.toml")):
93
+ if output.is_symlink():
94
+ continue
95
+ if output.name in expected or not _is_managed(output):
96
+ continue
97
+ output.unlink()
98
+ removed += 1
99
+ return removed
100
+
101
+
102
+ def _assert_safe_output_paths(base: Path, output_dir: Path) -> None:
103
+ if base.is_symlink():
104
+ raise RuntimeError(f"Refusing symlinked Codex config directory: {base}")
105
+ if output_dir.is_symlink():
106
+ raise RuntimeError(f"Refusing symlinked Codex agents directory: {output_dir}")
107
+
108
+
109
+ def _prepare_output_dir(base: Path) -> Path:
110
+ output_dir = base / "agents"
111
+ _assert_safe_output_paths(base, output_dir)
112
+ output_dir.mkdir(parents=True, exist_ok=True)
113
+ _assert_safe_output_paths(base, output_dir)
114
+ return output_dir
115
+
116
+
117
+ def _validate_agent_toml(rendered: str, source: Path) -> None:
118
+ data = tomllib.loads(rendered)
119
+ if set(data) != REQUIRED_FIELDS:
120
+ raise ValueError(f"Invalid Codex agent fields rendered from {source}")
121
+ if any(not isinstance(data[field], str) or not data[field] for field in REQUIRED_FIELDS):
122
+ raise ValueError(f"Empty Codex agent field rendered from {source}")
123
+
124
+
125
+ def _build_write_plan(
126
+ output_dir: Path,
127
+ unmanaged_names: set[str],
128
+ ) -> tuple[list[tuple[Path, str]], set[str]]:
129
+ plan: list[tuple[Path, str]] = []
130
+ expected: set[str] = set()
131
+
132
+ for agent_file in sorted(agents_dir.glob("*.md")):
133
+ name = frontmatter_field(agent_file, "name")
134
+ description = frontmatter_field(agent_file, "description")
135
+ if not name or not description:
136
+ continue
137
+ if name in unmanaged_names:
138
+ continue
139
+ filename = f"{AGENT_PREFIX}{name}.toml"
140
+ expected.add(filename)
141
+ output = output_dir / filename
142
+ if output.is_symlink():
143
+ _warn_preserved(output, "destination is a symlink")
144
+ continue
145
+ if output.exists() and not _is_managed(output):
146
+ continue
147
+ rendered = _render_agent(agent_file)
148
+ _validate_agent_toml(rendered, agent_file)
149
+ plan.append((output, rendered))
150
+ return plan, expected
151
+
152
+
153
+ def _discard_temp(path: Path, output_dir: Path) -> None:
154
+ if path.parent != output_dir:
155
+ return
156
+ try:
157
+ path.unlink(missing_ok=True)
158
+ except OSError:
159
+ pass
160
+
161
+
162
+ def _stage_agent(output_dir: Path, output: Path, rendered: str) -> Path:
163
+ fd, temp_name = tempfile.mkstemp(
164
+ dir=output_dir,
165
+ prefix=f".{output.name}.",
166
+ suffix=".tmp",
167
+ )
168
+ temp_path = Path(temp_name)
169
+ try:
170
+ handle = os.fdopen(fd, "w", encoding="utf-8")
171
+ fd = -1
172
+ with handle:
173
+ handle.write(rendered)
174
+ handle.flush()
175
+ os.fsync(handle.fileno())
176
+ _validate_agent_toml(temp_path.read_text(encoding="utf-8"), output)
177
+ return temp_path
178
+ except Exception:
179
+ if fd >= 0:
180
+ os.close(fd)
181
+ _discard_temp(temp_path, output_dir)
182
+ raise
183
+
184
+
185
+ def _stage_all(
186
+ output_dir: Path,
187
+ plan: list[tuple[Path, str]],
188
+ ) -> list[tuple[Path, Path]]:
189
+ staged: list[tuple[Path, Path]] = []
190
+ try:
191
+ for output, rendered in plan:
192
+ staged.append((_stage_agent(output_dir, output, rendered), output))
193
+ return staged
194
+ except Exception:
195
+ for temp_path, _ in staged:
196
+ _discard_temp(temp_path, output_dir)
197
+ raise
198
+
199
+
200
+ def _replace_all(
201
+ base: Path,
202
+ output_dir: Path,
203
+ staged: list[tuple[Path, Path]],
204
+ ) -> int:
205
+ written = 0
206
+ _assert_safe_output_paths(base, output_dir)
207
+ for temp_path, output in staged:
208
+ if output.is_symlink():
209
+ _warn_preserved(output, "destination became a symlink during generation")
210
+ continue
211
+ if output.exists() and not _is_managed(output):
212
+ _warn_preserved(output, "destination became user-owned during generation")
213
+ continue
214
+ os.replace(temp_path, output)
215
+ written += 1
216
+ return written
217
+
218
+
219
+ def generate(target_dir: Path, config_root: Path | None = None) -> tuple[int, int]:
220
+ base = config_root if config_root is not None else target_dir / ".codex"
221
+ output_dir = _prepare_output_dir(base)
222
+ unmanaged_names = _unmanaged_agent_names(output_dir)
223
+ plan, expected = _build_write_plan(output_dir, unmanaged_names)
224
+ staged = _stage_all(output_dir, plan)
225
+ try:
226
+ written = _replace_all(base, output_dir, staged)
227
+ _assert_safe_output_paths(base, output_dir)
228
+ finally:
229
+ for temp_path, _ in staged:
230
+ _discard_temp(temp_path, output_dir)
231
+ removed = _cleanup_stale(output_dir, expected)
232
+ return written, removed
233
+
234
+
235
+ def main() -> None:
236
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
237
+ written, removed = generate(target)
238
+ print(f"Generated: .codex/agents/ ({written} agents, {removed} stale removed)")
239
+
240
+
241
+ if __name__ == "__main__":
242
+ main()