@softspark/ai-toolkit 4.14.0 → 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 (48) hide show
  1. package/CHANGELOG.md +35 -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/agents/fact-checker.md +1 -1
  6. package/app/hooks/_search-capability.sh +3 -2
  7. package/app/hooks/stop-search-check.sh +2 -1
  8. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  9. package/kb/procedures/maintenance-sop.md +26 -13
  10. package/kb/procedures/release-verification-sop.md +41 -36
  11. package/kb/reference/architecture-overview.md +23 -7
  12. package/kb/reference/codex-cli-compatibility.md +96 -36
  13. package/kb/reference/extension-api.md +52 -9
  14. package/kb/reference/global-install-model.md +53 -21
  15. package/kb/reference/hooks-catalog.md +44 -8
  16. package/kb/reference/mcp-editor-compatibility.md +27 -6
  17. package/kb/reference/mcp-templates.md +12 -6
  18. package/kb/reference/opencode-compatibility.md +13 -7
  19. package/kb/reference/plugin-pack-conventions.md +7 -7
  20. package/kb/reference/skills-catalog.md +3 -3
  21. package/kb/reference/supported-tools-registry.md +19 -17
  22. package/kb/reference/windows-support.md +26 -3
  23. package/llms-full.txt +443 -180
  24. package/llms.txt +1 -1
  25. package/manifest.json +1 -1
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +448 -198
  28. package/scripts/dir_rules_shared.py +2 -11
  29. package/scripts/ecosystem_tools.json +29 -8
  30. package/scripts/emission.py +5 -91
  31. package/scripts/generate_agents_md.py +4 -87
  32. package/scripts/generate_codex.py +5 -95
  33. package/scripts/generate_codex_agents.py +242 -0
  34. package/scripts/generate_codex_hooks.py +648 -55
  35. package/scripts/generate_codex_skills.py +15 -6
  36. package/scripts/generate_copilot.py +771 -74
  37. package/scripts/generate_copilot_hooks.py +606 -0
  38. package/scripts/generate_cursor_hooks.py +453 -121
  39. package/scripts/generate_opencode_commands.py +4 -6
  40. package/scripts/inject_hook_cli.py +770 -205
  41. package/scripts/injection.py +102 -23
  42. package/scripts/install_steps/ai_tools.py +123 -83
  43. package/scripts/instruction_core.py +95 -0
  44. package/scripts/mcp_editors.py +934 -80
  45. package/scripts/mcp_manager.py +46 -26
  46. package/scripts/plugin.py +291 -114
  47. package/scripts/secure_fs.py +538 -0
  48. package/scripts/uninstall.py +1279 -208
@@ -7,9 +7,12 @@ Codex subagents and plan tracking.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import errno
11
+ import json
12
+ import os
10
13
  import re
11
- import shutil
12
14
  import sys
15
+ import tempfile
13
16
  from pathlib import Path
14
17
 
15
18
  try:
@@ -25,45 +28,91 @@ CLAUDE_ONLY_TOOLS = frozenset({
25
28
  "Skill", "EnterPlanMode", "ExitPlanMode",
26
29
  })
27
30
 
28
- CODEX_DELEGATION_TOOLS = (
29
- "spawn_agent", "send_input", "wait_agent", "close_agent", "update_plan",
30
- )
31
-
32
31
  ADAPTED_MARKER = ".ai-toolkit-codex-adapted"
33
32
 
34
33
  _FRONTMATTER_RE = re.compile(r"\A---\n(?P<frontmatter>.*?)\n---\n?(?P<body>.*)\Z", re.S)
35
- _SINGLE_AGENT_CALL_RE = re.compile(
36
- r'Agent\(subagent_type="([^"]+)",\s*prompt="([^"]+)"[^)]*\)'
37
- )
38
- _MULTILINE_AGENT_CALL_RE = re.compile(
39
- r'Agent\(\s*\n'
40
- r'\s*subagent_type="([^"]+)"\s*,\s*\n'
41
- r'\s*description="([^"]+)"\s*,\s*\n'
42
- r'\s*prompt="([^"]+)"\s*\n'
43
- r'\)',
44
- re.S,
45
- )
46
-
47
- _CODEX_NOTE = """
48
- ## Codex Translation Layer
49
-
50
- This generated Codex variant preserves the original workflow while translating
51
- Claude-only delegation primitives into Codex-native ones:
52
-
53
- - Use `spawn_agent(..., fork_context=True, ...)` instead of Claude `Agent(...)`.
54
- - Pick `explorer` for read-only discovery, `worker` for edits, `default` for
55
- synthesis, planning, and mixed execution.
56
- - Use `send_input` to redirect or clarify an active subagent.
57
- - Use `wait_agent` only when the next critical-path step is blocked on a
58
- delegated result.
59
- - Use `close_agent` after integrating finished subagents.
60
- - Track progress with `update_plan` or a local checklist instead of Claude
61
- task/team APIs.
62
- - Treat a "team" as a coordinated set of spawned subagents with explicit file
63
- ownership. No extra Codex feature flag is required.
34
+ _AGENT_START_RE = re.compile(r"\bAgent\s*\(")
35
+ _TASK_API_RE = re.compile(r"\bTask(?:Create|List|Update|Get|Output|Stop)\b")
36
+ _POSITIONAL_ARGUMENT_RE = re.compile(r"\$([1-9])\b")
37
+ _PLATFORM_LABELS = {"codex": "Codex", "opencode": "OpenCode"}
38
+ _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS = frozenset({
39
+ errno.EBADF,
40
+ errno.EINVAL,
41
+ getattr(errno, "ENOTSUP", errno.EINVAL),
42
+ getattr(errno, "EOPNOTSUPP", errno.EINVAL),
43
+ })
44
+ _ADAPTATION_BODY_TOKENS = frozenset({
45
+ "$ARGUMENTS",
46
+ "CLAUDE_SKILL_DIR",
47
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS",
48
+ "spawn_agent",
49
+ "send_input",
50
+ "wait_agent",
51
+ "close_agent",
52
+ "update_plan",
53
+ "fork_context",
54
+ "agent_type=",
55
+ "TeamCreate",
56
+ "TeamDelete",
57
+ "SendMessage",
58
+ })
59
+
60
+
61
+ def _translation_note(platform: str) -> str:
62
+ label = _PLATFORM_LABELS[platform]
63
+ return f"""
64
+ ## {label} Translation Layer
65
+
66
+ This generated {label} variant preserves the workflow intent using durable,
67
+ client-independent guidance:
68
+
69
+ - Use {label}-native subagents to delegate independent work when parallelism
70
+ materially improves speed or quality.
71
+ - Give each delegated task a narrow objective, relevant context, explicit file
72
+ ownership, and a clear expected result.
73
+ - Use the subagent controls available in the current client to steer or stop
74
+ delegated work without assuming a particular tool signature.
75
+ - Wait for delegated results only when the next critical-path step depends on
76
+ them, then integrate the results in the parent task.
77
+ - Track progress using the planning mechanism available in the current client
78
+ or an explicit local checklist.
79
+ - Treat a team as coordinated {label}-native subagents with non-overlapping work.
80
+ - Resolve `./` paths in command examples from the installed skill directory
81
+ that contains this `SKILL.md` file.
64
82
  """
65
83
 
66
84
 
85
+ def _semantic_replacements(platform: str) -> dict[str, str]:
86
+ label = _PLATFORM_LABELS[platform]
87
+ native_subagent = f"{label}-native subagent"
88
+ replacements = {
89
+ "${CLAUDE_SKILL_DIR}/": "./",
90
+ "$CLAUDE_SKILL_DIR/": "./",
91
+ "${CLAUDE_SKILL_DIR}": "the installed skill directory",
92
+ "CLAUDE_SKILL_DIR": "the installed skill directory",
93
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": f"{label} subagent support",
94
+ "spawn_agent": f"delegate independent work to a {native_subagent}",
95
+ "send_input": "steer a running subagent",
96
+ "wait_agent": "wait for delegated results",
97
+ "close_agent": "stop or finish delegated work",
98
+ "update_plan": "the planning mechanism available in the current client",
99
+ "fork_context": "appropriate inherited task context",
100
+ "agent_type=": "a suitable subagent role",
101
+ "TeamCreate": f"coordinate {label}-native subagents",
102
+ "TeamDelete": "finish coordinated subagent work",
103
+ "SendMessage": "steer a running subagent",
104
+ "TaskCreate": "the available planning mechanism",
105
+ "TaskList": "the available planning mechanism",
106
+ "TaskUpdate": "the available planning mechanism",
107
+ "TaskGet": "review delegated progress",
108
+ "TaskOutput": "collect delegated results",
109
+ "TaskStop": "stop delegated work",
110
+ }
111
+ if platform == "codex":
112
+ replacements["$ARGUMENTS"] = "the user-supplied task details"
113
+ return replacements
114
+
115
+
67
116
  def skill_tools(skill_file: Path) -> list[str]:
68
117
  """Return ordered allowed-tools entries from a skill frontmatter block."""
69
118
  tools_str = frontmatter_field(skill_file, "allowed-tools") or ""
@@ -71,8 +120,16 @@ def skill_tools(skill_file: Path) -> list[str]:
71
120
 
72
121
 
73
122
  def is_codex_adapted_skill(skill_file: Path) -> bool:
74
- """Return True if a skill needs Claude→Codex delegation adaptation."""
75
- return bool(set(skill_tools(skill_file)) & CLAUDE_ONLY_TOOLS)
123
+ """Return True when a source skill needs portable client adaptation."""
124
+ if set(skill_tools(skill_file)) & CLAUDE_ONLY_TOOLS:
125
+ return True
126
+ text = skill_file.read_text(encoding="utf-8")
127
+ if any(token in text for token in _ADAPTATION_BODY_TOKENS):
128
+ return True
129
+ if _AGENT_START_RE.search(text) or _TASK_API_RE.search(text):
130
+ return True
131
+ is_user_invocable = frontmatter_field(skill_file, "user-invocable") != "false"
132
+ return is_user_invocable and bool(_POSITIONAL_ARGUMENT_RE.search(text))
76
133
 
77
134
 
78
135
  def codex_skill_description(skill_file: Path) -> str:
@@ -81,95 +138,343 @@ def codex_skill_description(skill_file: Path) -> str:
81
138
  if not description:
82
139
  return ""
83
140
  if is_codex_adapted_skill(skill_file):
84
- return f"{description} Codex-adapted: uses native subagents and plan tracking."
141
+ return (
142
+ f"{description} Codex-adapted: uses Codex-native subagents and "
143
+ "current-client planning controls."
144
+ )
85
145
  return description
86
146
 
87
147
 
88
148
  def build_codex_skill_text(skill_file: Path) -> str:
89
149
  """Render the Codex-facing SKILL.md contents for a source skill."""
150
+ return _build_portable_skill_text(skill_file, "codex")
151
+
152
+
153
+ def build_opencode_skill_text(skill_file: Path) -> str:
154
+ """Render an OpenCode-facing skill without leaking Codex branding."""
155
+ return _build_portable_skill_text(skill_file, "opencode")
156
+
157
+
158
+ def _build_portable_skill_text(skill_file: Path, platform: str) -> str:
159
+ """Render a client-specific skill using semantic, signature-free guidance."""
160
+ if platform not in _PLATFORM_LABELS:
161
+ raise ValueError(f"Unsupported skill adaptation platform: {platform}")
90
162
  text = skill_file.read_text(encoding="utf-8")
91
163
  match = _FRONTMATTER_RE.match(text)
92
164
  if not match:
93
- return text
165
+ return _adapt_body(text, platform) if is_codex_adapted_skill(skill_file) else text
94
166
 
95
- frontmatter = _parse_frontmatter(match.group("frontmatter"))
96
167
  body = match.group("body")
97
168
  adapted = is_codex_adapted_skill(skill_file)
98
169
 
99
170
  if adapted:
100
- frontmatter = _adapt_frontmatter(frontmatter)
101
- body = _adapt_body(body)
171
+ body = _adapt_body(body, platform)
172
+ name = frontmatter_field(skill_file, "name")
173
+ description = (
174
+ codex_skill_description(skill_file)
175
+ if platform == "codex"
176
+ else frontmatter_field(skill_file, "description")
177
+ )
178
+ rendered_frontmatter = "\n".join(
179
+ (f"name: {name}", f"description: {json.dumps(description, ensure_ascii=False)}")
180
+ )
181
+ else:
182
+ frontmatter = _parse_frontmatter(match.group("frontmatter"))
183
+ rendered_frontmatter = _render_frontmatter(frontmatter)
102
184
 
103
- rendered_frontmatter = _render_frontmatter(frontmatter)
104
185
  return f"---\n{rendered_frontmatter}\n---\n{body.rstrip()}\n"
105
186
 
106
187
 
107
188
  def sync_codex_skill(skill_dir: Path, skills_dst: Path) -> str:
108
189
  """Install one skill into `.agents/skills/` and return its mode."""
109
190
  skill_file = skill_dir / "SKILL.md"
110
- target = skills_dst / skill_dir.name
111
191
  adapted = is_codex_adapted_skill(skill_file)
112
- marker = target / ADAPTED_MARKER
113
192
 
114
193
  if adapted:
115
- if target.is_symlink():
116
- target.unlink()
117
- elif target.is_dir() and not marker.is_file():
118
- return "skipped"
119
- elif target.is_dir():
120
- shutil.rmtree(target)
121
- elif target.exists():
122
- return "skipped"
194
+ return _sync_adapted_skill(skill_dir, skills_dst)
195
+ return _sync_native_skill(skill_dir, skills_dst)
196
+
197
+
198
+ def prepare_codex_skills_dir(target_dir: Path) -> Path:
199
+ """Create the documented Codex skill root without following symlinks."""
200
+ if target_dir.is_symlink():
201
+ raise RuntimeError(f"Refusing symlinked Codex target directory: {target_dir}")
202
+ agents_dir = target_dir / ".agents"
203
+ skills_dst = agents_dir / "skills"
204
+ _assert_safe_skill_roots(agents_dir, skills_dst)
205
+ skills_dst.mkdir(parents=True, exist_ok=True)
206
+ _assert_safe_skill_roots(agents_dir, skills_dst)
207
+ return skills_dst
208
+
209
+
210
+ def unmanaged_codex_skill_names(skills_dst: Path, skills_src: Path) -> set[str]:
211
+ """Return logical names declared by user-owned destination entries."""
212
+ names: set[str] = set()
213
+ for item in sorted(skills_dst.iterdir()):
214
+ if _is_managed_entry(item, skills_src):
215
+ continue
216
+ skill_file = item / "SKILL.md"
217
+ try:
218
+ name = frontmatter_field(skill_file, "name")
219
+ except (OSError, UnicodeError):
220
+ continue
221
+ if name:
222
+ names.add(name)
223
+ return names
224
+
225
+
226
+ def cleanup_codex_skills(
227
+ skills_dst: Path,
228
+ skills_src: Path,
229
+ blocked_names: set[str] | None = None,
230
+ ) -> None:
231
+ """Remove stale or shadowed toolkit-managed entries without touching user data."""
232
+ _assert_safe_skill_roots(skills_dst.parent, skills_dst)
233
+ skills_src_resolved = skills_src.resolve()
234
+ blocked_names = blocked_names or set()
235
+ for item in skills_dst.iterdir():
236
+ src = skills_src / item.name
237
+ if item.name in blocked_names and _is_managed_entry(item, skills_src):
238
+ if item.is_symlink():
239
+ item.unlink()
240
+ else:
241
+ _remove_managed_wrapper(item, skills_src_resolved)
242
+ continue
243
+ if item.is_symlink():
244
+ target = _symlink_target(item)
245
+ if src.is_dir() and target == src.resolve():
246
+ continue
247
+ if _is_relative_to(target, skills_src_resolved):
248
+ item.unlink()
249
+ continue
250
+ if _is_adapted_wrapper(item) and not src.is_dir():
251
+ _remove_managed_wrapper(item, skills_src_resolved)
123
252
 
124
- target.mkdir(parents=True, exist_ok=True)
125
- marker.write_text("generated by ai-toolkit for Codex\n", encoding="utf-8")
126
253
 
127
- for child in sorted(skill_dir.iterdir()):
128
- dest = target / child.name
129
- if child.name == "SKILL.md":
130
- continue
131
- if dest.is_symlink() or dest.is_file():
132
- dest.unlink()
133
- elif dest.is_dir():
134
- shutil.rmtree(dest)
135
- dest.symlink_to(child)
254
+ def _assert_safe_skill_roots(agents_dir: Path, skills_dst: Path) -> None:
255
+ if agents_dir.is_symlink():
256
+ raise RuntimeError(f"Refusing symlinked Codex agents directory: {agents_dir}")
257
+ if skills_dst.is_symlink():
258
+ raise RuntimeError(f"Refusing symlinked Codex skills directory: {skills_dst}")
259
+
260
+
261
+ def _symlink_target(path: Path) -> Path:
262
+ raw_target = Path(os.readlink(path))
263
+ if not raw_target.is_absolute():
264
+ raw_target = path.parent / raw_target
265
+ return raw_target.resolve(strict=False)
266
+
267
+
268
+ def _points_to(path: Path, target: Path) -> bool:
269
+ return path.is_symlink() and _symlink_target(path) == target.resolve()
136
270
 
137
- (target / "SKILL.md").write_text(build_codex_skill_text(skill_file), encoding="utf-8")
138
- return "adapted"
139
271
 
272
+ def _is_adapted_wrapper(path: Path) -> bool:
273
+ if path.is_symlink() or not path.is_dir():
274
+ return False
275
+ marker = path / ADAPTED_MARKER
276
+ return not marker.is_symlink() and marker.is_file()
277
+
278
+
279
+ def _is_managed_entry(path: Path, skills_src: Path) -> bool:
280
+ if _is_adapted_wrapper(path):
281
+ return True
282
+ if not path.is_symlink():
283
+ return False
284
+ return _is_relative_to(_symlink_target(path), skills_src.resolve())
285
+
286
+
287
+ def _sync_native_skill(skill_dir: Path, skills_dst: Path) -> str:
288
+ target = skills_dst / skill_dir.name
140
289
  if target.is_symlink():
141
- if target.resolve() == skill_dir.resolve():
142
- return "linked"
143
- target.unlink()
144
- elif target.is_dir():
145
- if marker.is_file():
146
- shutil.rmtree(target)
147
- else:
290
+ return "linked" if _points_to(target, skill_dir) else "skipped"
291
+ if _is_adapted_wrapper(target):
292
+ if not _remove_managed_wrapper(target, skill_dir.parent.resolve()):
148
293
  return "skipped"
149
294
  elif target.exists():
150
295
  return "skipped"
151
-
152
296
  target.symlink_to(skill_dir)
153
297
  return "linked"
154
298
 
155
299
 
156
- def cleanup_codex_skills(skills_dst: Path, skills_src: Path) -> None:
157
- """Remove broken symlinks and stale generated Codex skill wrappers."""
158
- skills_src_resolved = skills_src.resolve()
159
- for item in skills_dst.iterdir():
160
- src = skills_src / item.name
161
- if item.is_symlink():
162
- if not item.exists():
163
- item.unlink()
164
- continue
165
- target = item.resolve()
166
- if src.is_dir() and target == src.resolve():
167
- continue
168
- if _is_relative_to(target, skills_src_resolved):
169
- item.unlink()
300
+ def _sync_adapted_skill(skill_dir: Path, skills_dst: Path) -> str:
301
+ target = skills_dst / skill_dir.name
302
+ if target.is_symlink():
303
+ if not _points_to(target, skill_dir):
304
+ return "skipped"
305
+ return _create_adapted_wrapper(skill_dir, target, replace_managed_link=True)
306
+ if target.exists() and not _is_adapted_wrapper(target):
307
+ return "skipped"
308
+ if not target.exists():
309
+ return _create_adapted_wrapper(skill_dir, target)
310
+
311
+ return _update_adapted_wrapper(skill_dir, target)
312
+
313
+
314
+ def _create_adapted_wrapper(
315
+ skill_dir: Path,
316
+ target: Path,
317
+ *,
318
+ replace_managed_link: bool = False,
319
+ ) -> str:
320
+ """Build a complete sibling wrapper and expose it with one atomic rename."""
321
+ staging = Path(tempfile.mkdtemp(
322
+ dir=target.parent,
323
+ prefix=f".{target.name}.",
324
+ suffix=".tmp",
325
+ ))
326
+ removed_link = False
327
+ try:
328
+ _write_text_fsync(
329
+ staging / "SKILL.md",
330
+ build_codex_skill_text(skill_dir / "SKILL.md"),
331
+ )
332
+ _write_text_fsync(
333
+ staging / ADAPTED_MARKER,
334
+ "generated by ai-toolkit for Codex\n",
335
+ )
336
+ _sync_auxiliaries(skill_dir, staging)
337
+ _fsync_directory(staging)
338
+
339
+ if replace_managed_link:
340
+ if not _points_to(target, skill_dir):
341
+ raise RuntimeError(f"Codex skill target changed during sync: {target}")
342
+ target.unlink()
343
+ removed_link = True
344
+ elif target.exists() or target.is_symlink():
345
+ raise RuntimeError(f"Codex skill target appeared during sync: {target}")
346
+
347
+ os.replace(staging, target)
348
+ _fsync_directory(target.parent)
349
+ return "adapted"
350
+ except Exception:
351
+ _remove_staged_wrapper(staging)
352
+ if removed_link and not target.exists() and not target.is_symlink():
353
+ target.symlink_to(skill_dir)
354
+ raise
355
+
356
+
357
+ def _update_adapted_wrapper(skill_dir: Path, target: Path) -> str:
358
+ """Atomically refresh SKILL.md while leaving the managed marker stable."""
359
+ skill_output = target / "SKILL.md"
360
+ marker = target / ADAPTED_MARKER
361
+ if skill_output.is_symlink() or marker.is_symlink():
362
+ return "skipped"
363
+ temp_path = _stage_text(
364
+ skill_output,
365
+ build_codex_skill_text(skill_dir / "SKILL.md"),
366
+ )
367
+ try:
368
+ os.replace(temp_path, skill_output)
369
+ _fsync_directory(target)
370
+ finally:
371
+ temp_path.unlink(missing_ok=True)
372
+ _sync_auxiliaries(skill_dir, target)
373
+ return "adapted"
374
+
375
+
376
+ def _write_text_fsync(destination: Path, content: str) -> None:
377
+ with destination.open("x", encoding="utf-8") as handle:
378
+ handle.write(content)
379
+ handle.flush()
380
+ os.fsync(handle.fileno())
381
+
382
+
383
+ def _fsync_directory(path: Path) -> None:
384
+ if os.name == "nt":
385
+ return
386
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
387
+ try:
388
+ fd = os.open(path, flags)
389
+ except OSError as error:
390
+ if error.errno in _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS:
391
+ return
392
+ raise
393
+ try:
394
+ try:
395
+ os.fsync(fd)
396
+ except OSError as error:
397
+ if error.errno not in _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS:
398
+ raise
399
+ finally:
400
+ os.close(fd)
401
+
402
+
403
+ def _remove_staged_wrapper(staging: Path) -> None:
404
+ if staging.is_symlink() or not staging.is_dir():
405
+ return
406
+ for child in staging.iterdir():
407
+ if child.is_symlink() or child.is_file():
408
+ child.unlink()
409
+ try:
410
+ staging.rmdir()
411
+ except OSError:
412
+ pass
413
+
414
+
415
+ def _stage_text(destination: Path, content: str) -> Path:
416
+ fd, temp_name = tempfile.mkstemp(
417
+ dir=destination.parent,
418
+ prefix=f".{destination.name}.",
419
+ suffix=".tmp",
420
+ )
421
+ temp_path = Path(temp_name)
422
+ try:
423
+ handle = os.fdopen(fd, "w", encoding="utf-8")
424
+ fd = -1
425
+ with handle:
426
+ handle.write(content)
427
+ handle.flush()
428
+ os.fsync(handle.fileno())
429
+ return temp_path
430
+ except Exception:
431
+ if fd >= 0:
432
+ os.close(fd)
433
+ temp_path.unlink(missing_ok=True)
434
+ raise
435
+
436
+
437
+ def _sync_auxiliaries(skill_dir: Path, target: Path) -> None:
438
+ expected = {child.name for child in skill_dir.iterdir() if child.name != "SKILL.md"}
439
+ for child in sorted(skill_dir.iterdir()):
440
+ if child.name == "SKILL.md":
441
+ continue
442
+ destination = target / child.name
443
+ if destination.is_symlink():
444
+ if _points_to(destination, child):
170
445
  continue
171
- if item.is_dir() and (item / ADAPTED_MARKER).is_file() and not src.is_dir():
172
- shutil.rmtree(item)
446
+ continue
447
+ if destination.exists():
448
+ continue
449
+ destination.symlink_to(child)
450
+
451
+ source_root = skill_dir.resolve()
452
+ for destination in target.iterdir():
453
+ if destination.name in expected | {"SKILL.md", ADAPTED_MARKER}:
454
+ continue
455
+ if destination.is_symlink() and _is_relative_to(
456
+ _symlink_target(destination), source_root
457
+ ):
458
+ destination.unlink()
459
+
460
+
461
+ def _remove_managed_wrapper(path: Path, skills_src_resolved: Path) -> bool:
462
+ if not _is_adapted_wrapper(path):
463
+ return False
464
+ children = list(path.iterdir())
465
+ for child in children:
466
+ if child.name in {"SKILL.md", ADAPTED_MARKER}:
467
+ if child.is_symlink() or not child.is_file():
468
+ return False
469
+ continue
470
+ if not child.is_symlink():
471
+ return False
472
+ if not _is_relative_to(_symlink_target(child), skills_src_resolved):
473
+ return False
474
+ for child in children:
475
+ child.unlink()
476
+ path.rmdir()
477
+ return True
173
478
 
174
479
 
175
480
  def _is_relative_to(path: Path, parent: Path) -> bool:
@@ -194,113 +499,58 @@ def _render_frontmatter(entries: list[tuple[str, str]]) -> str:
194
499
  return "\n".join(f"{key}: {value}" for key, value in entries)
195
500
 
196
501
 
197
- def _adapt_frontmatter(entries: list[tuple[str, str]]) -> list[tuple[str, str]]:
198
- adapted: list[tuple[str, str]] = []
199
- for key, value in entries:
200
- if key in {"context", "agent", "model"}:
201
- continue
202
- if key == "description":
203
- stripped = _strip_quotes(value)
204
- value = f'"{stripped} Codex-adapted: uses native subagents and plan tracking."'
205
- elif key == "allowed-tools":
206
- value = ", ".join(_adapt_allowed_tools(value))
207
- adapted.append((key, value))
208
- return adapted
209
-
210
-
211
- def _adapt_allowed_tools(value: str) -> list[str]:
212
- tools = []
213
- for tool in [item.strip() for item in value.split(",") if item.strip()]:
214
- if tool in CLAUDE_ONLY_TOOLS:
502
+ def _adapt_body(body: str, platform: str) -> str:
503
+ label = _PLATFORM_LABELS[platform]
504
+ body = _replace_agent_calls(body, label)
505
+ body = body.replace("Agent Teams", f"coordinated {label}-native subagents")
506
+ body = body.replace("`Agent` tool", f"{label}-native subagents")
507
+ body = body.replace("the `Agent` tool", f"{label}-native subagents")
508
+ body = body.replace("Agent tool", f"{label}-native subagents")
509
+ for token, replacement in _semantic_replacements(platform).items():
510
+ body = body.replace(token, replacement)
511
+ if platform == "codex":
512
+ body = _POSITIONAL_ARGUMENT_RE.sub(
513
+ lambda match: f"the user-supplied positional task detail {match.group(1)}",
514
+ body,
515
+ )
516
+ return f"{_translation_note(platform).strip()}\n\n{body.strip()}\n"
517
+
518
+
519
+ def _replace_agent_calls(body: str, label: str) -> str:
520
+ """Replace balanced Agent calls without consuming surrounding markdown."""
521
+ rendered: list[str] = []
522
+ cursor = 0
523
+ while match := _AGENT_START_RE.search(body, cursor):
524
+ rendered.append(body[cursor:match.start()])
525
+ end = _balanced_call_end(body, match.end() - 1)
526
+ rendered.append(
527
+ f"Delegate this independent work to a suitable {label}-native subagent."
528
+ )
529
+ cursor = end
530
+ rendered.append(body[cursor:])
531
+ return "".join(rendered)
532
+
533
+
534
+ def _balanced_call_end(text: str, opening_parenthesis: int) -> int:
535
+ depth = 1
536
+ quote: str | None = None
537
+ is_escaped = False
538
+ for index in range(opening_parenthesis + 1, len(text)):
539
+ character = text[index]
540
+ if quote is not None:
541
+ if is_escaped:
542
+ is_escaped = False
543
+ elif character == "\\":
544
+ is_escaped = True
545
+ elif character == quote:
546
+ quote = None
215
547
  continue
216
- if tool not in tools:
217
- tools.append(tool)
218
- for tool in CODEX_DELEGATION_TOOLS:
219
- if tool not in tools:
220
- tools.append(tool)
221
- return tools
222
-
223
-
224
- def _adapt_body(body: str) -> str:
225
- body = body.replace(
226
- "## MANDATORY: You MUST use the Agent tool",
227
- "## MANDATORY: Use Codex subagents for delegation",
228
- )
229
- body = body.replace("`Agent` tool", "Codex subagent tools")
230
- body = body.replace("the `Agent` tool", "Codex subagent tools")
231
- body = body.replace("Agent tool", "Codex subagent tools")
232
- body = body.replace("Agent Teams", "parallel Codex subagents")
233
- body = body.replace(
234
- "Requires: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`",
235
- "Requires: native Codex subagent support (`spawn_agent`, `send_input`, `wait_agent`, `close_agent`)",
236
- )
237
- body = body.replace(
238
- "Check `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is set; warn if not",
239
- "Confirm the user explicitly wants delegated/subagent execution before launching workers",
240
- )
241
- body = body.replace(
242
- "export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1",
243
- "# No extra environment variable is required in Codex",
244
- )
245
- body = body.replace("TaskCreate", "update_plan")
246
- body = body.replace("TaskList", "update_plan")
247
- body = body.replace("TaskUpdate", "update_plan")
248
- body = body.replace("TaskGet", "wait_agent")
249
- body = body.replace("TaskOutput", "wait_agent")
250
- body = body.replace("TaskStop", "close_agent")
251
- body = body.replace("SendMessage", "send_input")
252
- body = body.replace("TeamCreate", "spawn_agent")
253
- body = body.replace("TeamDelete", "close_agent")
254
-
255
- body = _MULTILINE_AGENT_CALL_RE.sub(_replace_multiline_agent_call, body)
256
- body = _SINGLE_AGENT_CALL_RE.sub(_replace_single_agent_call, body)
257
-
258
- if "$ARGUMENTS" in body:
259
- body = body.replace("$ARGUMENTS", f"$ARGUMENTS\n{_CODEX_NOTE.rstrip()}", 1)
260
-
261
- return body
262
-
263
-
264
- def _replace_single_agent_call(match: re.Match[str]) -> str:
265
- role = match.group(1)
266
- prompt = match.group(2)
267
- agent_type = _codex_agent_type(role, prompt)
268
- escaped_prompt = prompt.replace('"', "'")
269
- return (
270
- f'spawn_agent(agent_type="{agent_type}", fork_context=True, '
271
- f'message="Act as {role}. {escaped_prompt}")'
272
- )
273
-
274
-
275
- def _replace_multiline_agent_call(match: re.Match[str]) -> str:
276
- role = match.group(1)
277
- prompt = match.group(3).replace('"', "'")
278
- return (
279
- "spawn_agent(\n"
280
- f' agent_type="{_codex_agent_type(role, prompt)}",\n'
281
- " fork_context=True,\n"
282
- f' message="Act as {role}. {prompt}"\n'
283
- ")"
284
- )
285
-
286
-
287
- def _codex_agent_type(role: str, prompt: str) -> str:
288
- role_l = role.lower()
289
- prompt_l = prompt.lower()
290
- if role_l == "explorer-agent":
291
- return "explorer"
292
- if any(token in prompt_l for token in ("read-only", "review", "audit", "trace", "map ", "analyze")):
293
- return "default"
294
- if any(token in prompt_l for token in (
295
- "own files:", "implement", "write ", "apply", "build ", "update ",
296
- "create ", "execute ", "fix", "document", "deploy",
297
- )):
298
- return "worker"
299
- return "default"
300
-
301
-
302
- def _strip_quotes(value: str) -> str:
303
- value = value.strip()
304
- if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
305
- return value[1:-1]
306
- return value
548
+ if character in {"'", '"'}:
549
+ quote = character
550
+ elif character == "(":
551
+ depth += 1
552
+ elif character == ")":
553
+ depth -= 1
554
+ if depth == 0:
555
+ return index + 1
556
+ raise ValueError(f"Unbalanced Agent call at character {opening_parenthesis}")