@softspark/ai-toolkit 4.29.2 → 4.30.2

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 +82 -0
  2. package/README.md +44 -18
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/ARCHITECTURE.md +2 -2
  5. package/app/mcp-templates/README.md +7 -2
  6. package/app/mcp-templates/rag-mcp-legal.json +11 -0
  7. package/app/mcp-templates/rag-mcp.json +11 -0
  8. package/app/surface.json +1 -0
  9. package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
  10. package/bin/ai-toolkit.js +8 -0
  11. package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
  12. package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
  13. package/kb/procedures/ecosystem-sync-sop.md +7 -5
  14. package/kb/procedures/maintenance-sop.md +1 -1
  15. package/kb/procedures/release-verification-sop.md +35 -5
  16. package/kb/reference/architecture-overview.md +24 -5
  17. package/kb/reference/cli-reference.md +1 -1
  18. package/kb/reference/dsh-compatibility.md +183 -0
  19. package/kb/reference/manifest-install.md +112 -5
  20. package/kb/reference/mcp-templates.md +11 -4
  21. package/kb/reference/plugin-pack-conventions.md +35 -18
  22. package/kb/reference/supported-tools-registry.md +30 -6
  23. package/llms-full.txt +1110 -50
  24. package/llms.txt +3 -0
  25. package/manifest.json +2 -2
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +673 -34
  28. package/scripts/config_resolver.py +80 -14
  29. package/scripts/doctor.py +98 -20
  30. package/scripts/ecosystem_tools.json +51 -1
  31. package/scripts/generate_codex_skills.py +22 -20
  32. package/scripts/install.py +30 -13
  33. package/scripts/install_steps/ai_tools.py +97 -33
  34. package/scripts/install_steps/dsh.py +5063 -0
  35. package/scripts/install_steps/install_state.py +1645 -57
  36. package/scripts/mcp_editors.py +5 -2
  37. package/scripts/plugin.py +2495 -163
  38. package/scripts/plugin_mcp.py +279 -0
  39. package/scripts/plugin_rules.py +389 -0
  40. package/scripts/plugin_schema.py +139 -23
  41. package/scripts/uninstall.py +47 -4
  42. package/scripts/validate.py +421 -0
@@ -2,12 +2,12 @@
2
2
  # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
3
3
  # Source: https://github.com/softspark/ai-toolkit
4
4
 
5
- """Adapt ai-toolkit skills for Codex CLI.
5
+ """Adapt ai-toolkit skills for clients sharing ``.agents/skills``.
6
6
 
7
7
  Compatible skills are symlinked as-is. Skills that rely on Claude-only
8
- delegation primitives are rendered into Codex-specific wrappers that keep the
9
- same references/assets but rewrite the main SKILL.md guidance to use native
10
- Codex subagents and plan tracking.
8
+ delegation primitives are rendered into Codex-specific or portable DSH
9
+ wrappers. Generated variants retain shared references and assets while using
10
+ guidance appropriate for the selected discovery-surface owners.
11
11
  """
12
12
  from __future__ import annotations
13
13
 
@@ -15,9 +15,11 @@ import errno
15
15
  import json
16
16
  import os
17
17
  import re
18
+ import stat
18
19
  import sys
19
20
  import tempfile
20
21
  from pathlib import Path
22
+ from typing import NamedTuple
21
23
 
22
24
  try:
23
25
  from frontmatter import frontmatter_field
@@ -33,6 +35,15 @@ CLAUDE_ONLY_TOOLS = frozenset({
33
35
  })
34
36
 
35
37
  ADAPTED_MARKER = ".ai-toolkit-codex-adapted"
38
+ DSH_ADAPTED_MARKER = ".ai-toolkit-dsh-adapted"
39
+ SHARED_ADAPTED_MARKER = ".ai-toolkit-shared-adapted"
40
+ SKILL_SURFACE_OWNERS_MARKER = ".ai-toolkit-skill-owners"
41
+ ADAPTED_MARKERS = frozenset({
42
+ ADAPTED_MARKER,
43
+ DSH_ADAPTED_MARKER,
44
+ SHARED_ADAPTED_MARKER,
45
+ })
46
+ SKILL_SURFACE_OWNERS = frozenset({"codex", "dsh"})
36
47
 
37
48
  _FRONTMATTER_RE = re.compile(r"\A---\n(?P<frontmatter>.*?)\n---\n?(?P<body>.*)\Z", re.S)
38
49
  _AGENT_START_RE = re.compile(r"\bAgent\s*\(")
@@ -62,7 +73,48 @@ _ADAPTATION_BODY_TOKENS = frozenset({
62
73
  })
63
74
 
64
75
 
76
+ class _OwnerMarkerState(NamedTuple):
77
+ data: bytes | None
78
+ signature: tuple[int, int, int, int, int] | None
79
+
80
+
81
+ class _ManagedChildState(NamedTuple):
82
+ kind: str
83
+ content: bytes | str
84
+ permissions: int = 0
85
+
86
+
87
+ class _ManagedEntryState(NamedTuple):
88
+ kind: str
89
+ content: str | dict[str, _ManagedChildState]
90
+
91
+
92
+ class _RegularFileState(NamedTuple):
93
+ data: bytes
94
+ signature: tuple[int, int, int, int, int]
95
+ permissions: int
96
+
97
+
65
98
  def _translation_note(platform: str) -> str:
99
+ if platform == "dsh":
100
+ return """
101
+ ## Portable Translation Layer
102
+
103
+ This generated variant preserves the workflow intent using durable,
104
+ client-independent guidance:
105
+
106
+ - Use available subagents when delegation materially improves speed or quality.
107
+ - Give delegated work a narrow objective, relevant context, explicit file
108
+ ownership, and a clear expected result.
109
+ - Use the controls available in the current client to steer or stop delegated
110
+ work without assuming a particular tool signature.
111
+ - Wait for delegated results only when the next critical-path step depends on
112
+ them, then integrate the results in the parent task.
113
+ - Track progress using the planning mechanism available in the current client
114
+ or an explicit local checklist.
115
+ - Resolve `./` paths in command examples from the installed skill directory
116
+ that contains this `SKILL.md` file.
117
+ """
66
118
  label = _PLATFORM_LABELS[platform]
67
119
  return f"""
68
120
  ## {label} Translation Layer
@@ -87,6 +139,31 @@ client-independent guidance:
87
139
 
88
140
 
89
141
  def _semantic_replacements(platform: str) -> dict[str, str]:
142
+ if platform == "dsh":
143
+ return {
144
+ "${CLAUDE_SKILL_DIR}/": "./",
145
+ "$CLAUDE_SKILL_DIR/": "./",
146
+ "${CLAUDE_SKILL_DIR}": "the installed skill directory",
147
+ "CLAUDE_SKILL_DIR": "the installed skill directory",
148
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "subagent support",
149
+ "spawn_agent": "delegate independent work to an available subagent",
150
+ "send_input": "steer a running subagent",
151
+ "wait_agent": "wait for delegated results",
152
+ "close_agent": "stop or finish delegated work",
153
+ "update_plan": "the planning mechanism available in the current client",
154
+ "fork_context": "appropriate inherited task context",
155
+ "agent_type=": "a suitable subagent role",
156
+ "TeamCreate": "coordinate available subagents",
157
+ "TeamDelete": "finish coordinated subagent work",
158
+ "SendMessage": "steer a running subagent",
159
+ "TaskCreate": "the available planning mechanism",
160
+ "TaskList": "the available planning mechanism",
161
+ "TaskUpdate": "the available planning mechanism",
162
+ "TaskGet": "review delegated progress",
163
+ "TaskOutput": "collect delegated results",
164
+ "TaskStop": "stop delegated work",
165
+ "$ARGUMENTS": "the user-supplied task details",
166
+ }
90
167
  label = _PLATFORM_LABELS[platform]
91
168
  native_subagent = f"{label}-native subagent"
92
169
  replacements = {
@@ -159,9 +236,23 @@ def build_opencode_skill_text(skill_file: Path) -> str:
159
236
  return _build_portable_skill_text(skill_file, "opencode")
160
237
 
161
238
 
239
+ def build_dsh_skill_text(skill_file: Path) -> str:
240
+ """Render a platform-neutral DSH skill with invocation metadata intact."""
241
+ return _build_portable_skill_text(skill_file, "dsh")
242
+
243
+
244
+ def _build_managed_skill_text(skill_file: Path, platform: str) -> str:
245
+ """Dispatch managed wrapper rendering without changing legacy APIs."""
246
+ if platform == "codex":
247
+ return build_codex_skill_text(skill_file)
248
+ if platform == "dsh":
249
+ return build_dsh_skill_text(skill_file)
250
+ raise ValueError(f"Unsupported managed skill platform: {platform}")
251
+
252
+
162
253
  def _build_portable_skill_text(skill_file: Path, platform: str) -> str:
163
254
  """Render a client-specific skill using semantic, signature-free guidance."""
164
- if platform not in _PLATFORM_LABELS:
255
+ if platform not in {*_PLATFORM_LABELS, "dsh"}:
165
256
  raise ValueError(f"Unsupported skill adaptation platform: {platform}")
166
257
  text = skill_file.read_text(encoding="utf-8")
167
258
  match = _FRONTMATTER_RE.match(text)
@@ -179,9 +270,15 @@ def _build_portable_skill_text(skill_file: Path, platform: str) -> str:
179
270
  if platform == "codex"
180
271
  else frontmatter_field(skill_file, "description")
181
272
  )
182
- rendered_frontmatter = "\n".join(
183
- (f"name: {name}", f"description: {json.dumps(description, ensure_ascii=False)}")
184
- )
273
+ rendered_entries = [
274
+ ("name", str(name)),
275
+ ("description", json.dumps(description, ensure_ascii=False)),
276
+ ]
277
+ rendered_frontmatter = _render_frontmatter(rendered_entries)
278
+ if platform == "dsh":
279
+ invocation_lines = _invocation_metadata_lines(match.group("frontmatter"))
280
+ if invocation_lines:
281
+ rendered_frontmatter += "\n" + "\n".join(invocation_lines)
185
282
  else:
186
283
  frontmatter = _parse_frontmatter(match.group("frontmatter"))
187
284
  rendered_frontmatter = _render_frontmatter(frontmatter)
@@ -199,8 +296,23 @@ def sync_codex_skill(skill_dir: Path, skills_dst: Path) -> str:
199
296
  return _sync_native_skill(skill_dir, skills_dst)
200
297
 
201
298
 
299
+ def sync_dsh_skill(skill_dir: Path, skills_dst: Path, *, shared: bool = False) -> str:
300
+ """Install one skill for DSH, optionally on a Codex-shared surface."""
301
+ skill_file = skill_dir / "SKILL.md"
302
+ if not is_codex_adapted_skill(skill_file):
303
+ return _sync_native_skill(skill_dir, skills_dst)
304
+ marker = SHARED_ADAPTED_MARKER if shared else DSH_ADAPTED_MARKER
305
+ return _sync_adapted_skill(
306
+ skill_dir,
307
+ skills_dst,
308
+ platform="dsh",
309
+ marker_name=marker,
310
+ )
311
+
312
+
202
313
  def prepare_codex_skills_dir(target_dir: Path) -> Path:
203
314
  """Create the documented Codex skill root without following symlinks."""
315
+ _preflight_skill_surface_owner_marker(target_dir)
204
316
  if target_dir.is_symlink():
205
317
  raise RuntimeError(f"Refusing symlinked Codex target directory: {target_dir}")
206
318
  agents_dir = target_dir / ".agents"
@@ -211,6 +323,373 @@ def prepare_codex_skills_dir(target_dir: Path) -> Path:
211
323
  return skills_dst
212
324
 
213
325
 
326
+ def _preflight_skill_surface_owner_marker(target_dir: Path) -> _OwnerMarkerState:
327
+ """Validate and snapshot ownership before any skill-root mutation."""
328
+ return _read_owner_marker_state(
329
+ target_dir / ".agents" / SKILL_SURFACE_OWNERS_MARKER
330
+ )
331
+
332
+
333
+ def _read_owner_marker_state(marker: Path) -> _OwnerMarkerState:
334
+ """Return the exact valid marker state, rejecting every unsafe collision."""
335
+ if marker.is_symlink():
336
+ raise RuntimeError(
337
+ f"Refusing invalid skill-surface owner marker {marker}: "
338
+ "symlinks are not managed owner markers"
339
+ )
340
+ if not marker.exists():
341
+ return _OwnerMarkerState(data=None, signature=None)
342
+ try:
343
+ metadata = marker.lstat()
344
+ except OSError as error:
345
+ raise RuntimeError(
346
+ f"Refusing invalid skill-surface owner marker {marker}: "
347
+ f"cannot inspect marker ({error})"
348
+ ) from error
349
+ if stat.S_ISDIR(metadata.st_mode):
350
+ raise RuntimeError(
351
+ f"Refusing invalid skill-surface owner marker {marker}: "
352
+ "directory found where a regular marker file is required"
353
+ )
354
+ if not stat.S_ISREG(metadata.st_mode):
355
+ raise RuntimeError(
356
+ f"Refusing invalid skill-surface owner marker {marker}: "
357
+ "only a regular non-symlink marker file is supported"
358
+ )
359
+ try:
360
+ data = marker.read_bytes()
361
+ except (OSError, UnicodeError) as error:
362
+ raise RuntimeError(
363
+ f"Refusing invalid skill-surface owner marker {marker}: "
364
+ f"cannot read regular marker ({error})"
365
+ ) from error
366
+ if parse_skill_surface_owners(data) is None:
367
+ raise RuntimeError(
368
+ f"Refusing invalid skill-surface owner marker {marker}: expected "
369
+ "one canonical owner per line (codex and/or dsh)"
370
+ )
371
+ signature = (
372
+ metadata.st_dev,
373
+ metadata.st_ino,
374
+ metadata.st_mode,
375
+ metadata.st_size,
376
+ metadata.st_mtime_ns,
377
+ )
378
+ return _OwnerMarkerState(data=data, signature=signature)
379
+
380
+
381
+ def serialize_skill_surface_owners(owners: set[str]) -> bytes:
382
+ """Serialize one non-empty known owner set into its only managed form."""
383
+ if not owners or not owners <= SKILL_SURFACE_OWNERS:
384
+ raise ValueError(f"Invalid .agents/skills owners: {sorted(owners)}")
385
+ return ("\n".join(sorted(owners)) + "\n").encode("utf-8")
386
+
387
+
388
+ def parse_skill_surface_owners(data: bytes) -> set[str] | None:
389
+ """Return owners only when bytes exactly match the canonical serialization."""
390
+ try:
391
+ lines = data.decode("utf-8").splitlines()
392
+ except UnicodeError:
393
+ return None
394
+ owners = set(lines)
395
+ if len(lines) != len(owners):
396
+ return None
397
+ try:
398
+ canonical = serialize_skill_surface_owners(owners)
399
+ except ValueError:
400
+ return None
401
+ return owners if data == canonical else None
402
+
403
+
404
+ def _assert_owner_marker_state(marker: Path, expected: _OwnerMarkerState) -> None:
405
+ try:
406
+ actual = _read_owner_marker_state(marker)
407
+ except RuntimeError as error:
408
+ raise RuntimeError(
409
+ f"Skill-surface owner marker changed before atomic replacement: {marker}"
410
+ ) from error
411
+ if actual != expected:
412
+ raise RuntimeError(
413
+ f"Skill-surface owner marker changed before atomic replacement: {marker}"
414
+ )
415
+
416
+
417
+ def _restore_owner_marker(marker: Path, expected: _OwnerMarkerState) -> None:
418
+ try:
419
+ actual = _read_owner_marker_state(marker)
420
+ except RuntimeError as error:
421
+ raise RuntimeError(
422
+ f"Cannot restore changed skill-surface owner marker: {marker}"
423
+ ) from error
424
+ if actual == expected:
425
+ return
426
+ if expected.data is None:
427
+ if actual.data is None:
428
+ return
429
+ marker.unlink()
430
+ _fsync_directory(marker.parent)
431
+ return
432
+ temp_path = _stage_bytes(marker, expected.data)
433
+ try:
434
+ os.replace(temp_path, marker)
435
+ _fsync_directory(marker.parent)
436
+ finally:
437
+ temp_path.unlink(missing_ok=True)
438
+
439
+
440
+ def _directory_entry_names(path: Path) -> set[str]:
441
+ if not path.is_dir() or path.is_symlink():
442
+ return set()
443
+ return {entry.name for entry in path.iterdir()}
444
+
445
+
446
+ def _snapshot_managed_entries(
447
+ skills_dst: Path,
448
+ skills_src: Path,
449
+ ) -> dict[str, _ManagedEntryState]:
450
+ if not skills_dst.is_dir() or skills_dst.is_symlink():
451
+ return {}
452
+ source_root = skills_src.resolve()
453
+ snapshots: dict[str, _ManagedEntryState] = {}
454
+ for entry in skills_dst.iterdir():
455
+ if entry.is_symlink():
456
+ if _is_relative_to(_symlink_target(entry), source_root):
457
+ snapshots[entry.name] = _ManagedEntryState(
458
+ kind="link",
459
+ content=os.readlink(entry),
460
+ )
461
+ continue
462
+ marker_name = _validated_wrapper_marker(entry)
463
+ if marker_name is None:
464
+ continue
465
+ children: dict[str, _ManagedChildState] = {}
466
+ for child in entry.iterdir():
467
+ if child.name == "SKILL.md" or child.name in ADAPTED_MARKERS:
468
+ if child.is_symlink() or not child.is_file():
469
+ raise RuntimeError(
470
+ f"Cannot transactionally manage malformed skill wrapper: {child}"
471
+ )
472
+ metadata = child.stat()
473
+ children[child.name] = _ManagedChildState(
474
+ kind="file",
475
+ content=child.read_bytes(),
476
+ permissions=stat.S_IMODE(metadata.st_mode),
477
+ )
478
+ continue
479
+ if child.is_symlink() and _is_relative_to(
480
+ _symlink_target(child), source_root
481
+ ):
482
+ children[child.name] = _ManagedChildState(
483
+ kind="link",
484
+ content=os.readlink(child),
485
+ )
486
+ if "SKILL.md" not in children or marker_name not in children:
487
+ raise RuntimeError(f"Cannot snapshot incomplete skill wrapper: {entry}")
488
+ snapshots[entry.name] = _ManagedEntryState(
489
+ kind="wrapper",
490
+ content=children,
491
+ )
492
+ return snapshots
493
+
494
+
495
+ def _validated_wrapper_marker(path: Path) -> str | None:
496
+ if path.is_symlink() or not path.is_dir():
497
+ return None
498
+ present = [
499
+ marker_name
500
+ for marker_name in sorted(ADAPTED_MARKERS)
501
+ if (path / marker_name).exists() or (path / marker_name).is_symlink()
502
+ ]
503
+ if not present:
504
+ return None
505
+ if len(present) != 1:
506
+ raise RuntimeError(f"Ambiguous managed skill wrapper markers: {path}")
507
+ marker = path / present[0]
508
+ if marker.is_symlink() or not marker.is_file():
509
+ raise RuntimeError(f"Malformed managed skill wrapper marker: {marker}")
510
+ return present[0]
511
+
512
+
513
+ def _remove_transaction_entry(path: Path, source_root: Path) -> None:
514
+ if path.is_symlink():
515
+ if _is_relative_to(_symlink_target(path), source_root):
516
+ path.unlink()
517
+ return
518
+ if not path.is_dir():
519
+ return
520
+ if _validated_wrapper_marker(path) is None:
521
+ return
522
+ _remove_transaction_children(path, source_root)
523
+ try:
524
+ path.rmdir()
525
+ except OSError:
526
+ pass
527
+
528
+
529
+ def _remove_transaction_children(path: Path, source_root: Path) -> None:
530
+ for child in list(path.iterdir()):
531
+ if child.name == "SKILL.md" or child.name in ADAPTED_MARKERS:
532
+ if child.is_symlink() or child.is_file():
533
+ child.unlink()
534
+ continue
535
+ if child.is_symlink() and _is_relative_to(
536
+ _symlink_target(child), source_root
537
+ ):
538
+ child.unlink()
539
+
540
+
541
+ def _restore_managed_entry(
542
+ path: Path,
543
+ expected: _ManagedEntryState,
544
+ source_root: Path,
545
+ ) -> None:
546
+ if expected.kind == "link":
547
+ _remove_transaction_entry(path, source_root)
548
+ if path.exists() or path.is_symlink():
549
+ if path.is_symlink() and os.readlink(path) == expected.content:
550
+ return
551
+ raise RuntimeError(f"Cannot restore managed skill link without data loss: {path}")
552
+ path.symlink_to(str(expected.content))
553
+ return
554
+
555
+ if path.is_symlink():
556
+ if not _is_relative_to(_symlink_target(path), source_root):
557
+ raise RuntimeError(f"Cannot replace user-owned skill link: {path}")
558
+ path.unlink()
559
+ if path.exists() and not path.is_dir():
560
+ raise RuntimeError(f"Cannot restore managed skill wrapper: {path}")
561
+ path.mkdir(exist_ok=True)
562
+ _remove_transaction_children(path, source_root)
563
+ children = expected.content
564
+ if not isinstance(children, dict):
565
+ raise RuntimeError(f"Invalid managed wrapper snapshot: {path}")
566
+ for name, child_state in children.items():
567
+ destination = path / name
568
+ if child_state.kind == "link":
569
+ destination.symlink_to(str(child_state.content))
570
+ continue
571
+ data = child_state.content
572
+ if not isinstance(data, bytes):
573
+ raise RuntimeError(f"Invalid managed file snapshot: {destination}")
574
+ temp_path = _stage_bytes(destination, data)
575
+ try:
576
+ os.chmod(temp_path, child_state.permissions)
577
+ os.replace(temp_path, destination)
578
+ finally:
579
+ temp_path.unlink(missing_ok=True)
580
+ _fsync_directory(path)
581
+
582
+
583
+ def set_skill_surface_owners(
584
+ skills_dst: Path,
585
+ owners: set[str],
586
+ *,
587
+ expected_state: _OwnerMarkerState | None = None,
588
+ ) -> None:
589
+ """Record explicit owners for the otherwise ambiguous shared directory."""
590
+ _assert_safe_skill_roots(skills_dst.parent, skills_dst)
591
+ marker = skills_dst.parent / SKILL_SURFACE_OWNERS_MARKER
592
+ previous = expected_state or _read_owner_marker_state(marker)
593
+ temp_path = _stage_bytes(marker, serialize_skill_surface_owners(owners))
594
+ try:
595
+ _assert_owner_marker_state(marker, previous)
596
+ os.replace(temp_path, marker)
597
+ _fsync_directory(skills_dst.parent)
598
+ finally:
599
+ temp_path.unlink(missing_ok=True)
600
+
601
+
602
+ def skill_surface_owners(skills_dst: Path) -> set[str] | None:
603
+ """Return declared owners, ``None`` for a legacy unowned surface."""
604
+ marker = skills_dst.parent / SKILL_SURFACE_OWNERS_MARKER
605
+ try:
606
+ state = _read_owner_marker_state(marker)
607
+ except RuntimeError:
608
+ return set()
609
+ if state.data is None:
610
+ return None
611
+ owners = parse_skill_surface_owners(state.data)
612
+ return owners if owners is not None else set()
613
+
614
+
615
+ class _SkillSurfaceTransaction:
616
+ """Rollback guard for the installer-owned portion of `.agents/skills`."""
617
+
618
+ def __init__(self, target_dir: Path, skills_src: Path) -> None:
619
+ self.target_dir = target_dir
620
+ self.skills_src = skills_src
621
+ self.agents_dir = target_dir / ".agents"
622
+ self.skills_dst = self.agents_dir / "skills"
623
+ self.marker = self.agents_dir / SKILL_SURFACE_OWNERS_MARKER
624
+ self.marker_state = _read_owner_marker_state(self.marker)
625
+ self.agents_existed = self.agents_dir.is_dir()
626
+ self.skills_existed = self.skills_dst.is_dir()
627
+ _assert_safe_skill_roots(self.agents_dir, self.skills_dst)
628
+ self.entries = _snapshot_managed_entries(self.skills_dst, skills_src)
629
+ self.initial_names = _directory_entry_names(self.skills_dst)
630
+ self.is_committed = False
631
+
632
+ def __enter__(self) -> _SkillSurfaceTransaction:
633
+ self.skills_dst = prepare_codex_skills_dir(self.target_dir)
634
+ return self
635
+
636
+ def commit(self, owners: set[str]) -> None:
637
+ set_skill_surface_owners(
638
+ self.skills_dst,
639
+ owners,
640
+ expected_state=self.marker_state,
641
+ )
642
+ self.is_committed = True
643
+
644
+ def __exit__(self, error_type, error, traceback) -> bool:
645
+ if error is None and self.is_committed:
646
+ return False
647
+ try:
648
+ self._rollback()
649
+ except (OSError, RuntimeError) as rollback_error:
650
+ if error is None:
651
+ raise
652
+ raise RuntimeError(
653
+ f"Managed skill transaction failed and rollback was incomplete: "
654
+ f"{rollback_error}"
655
+ ) from error
656
+ return False
657
+
658
+ def _rollback(self) -> None:
659
+ if self.skills_dst.is_dir() and not self.skills_dst.is_symlink():
660
+ current_names = _directory_entry_names(self.skills_dst)
661
+ for name in sorted(current_names - self.initial_names):
662
+ _remove_transaction_entry(
663
+ self.skills_dst / name,
664
+ self.skills_src.resolve(),
665
+ )
666
+ for name, entry_state in self.entries.items():
667
+ _restore_managed_entry(
668
+ self.skills_dst / name,
669
+ entry_state,
670
+ self.skills_src.resolve(),
671
+ )
672
+ _restore_owner_marker(self.marker, self.marker_state)
673
+ if not self.skills_existed and self.skills_dst.is_dir():
674
+ try:
675
+ self.skills_dst.rmdir()
676
+ except OSError:
677
+ pass
678
+ if not self.agents_existed and self.agents_dir.is_dir():
679
+ try:
680
+ self.agents_dir.rmdir()
681
+ except OSError:
682
+ pass
683
+
684
+
685
+ def managed_skill_surface_transaction(
686
+ target_dir: Path,
687
+ skills_src: Path,
688
+ ) -> _SkillSurfaceTransaction:
689
+ """Return a rollback guard for one complete managed skill installation."""
690
+ return _SkillSurfaceTransaction(target_dir, skills_src)
691
+
692
+
214
693
  def unmanaged_codex_skill_names(skills_dst: Path, skills_src: Path) -> set[str]:
215
694
  """Return logical names declared by user-owned destination entries."""
216
695
  names: set[str] = set()
@@ -273,11 +752,18 @@ def _points_to(path: Path, target: Path) -> bool:
273
752
  return path.is_symlink() and _symlink_target(path) == target.resolve()
274
753
 
275
754
 
276
- def _is_adapted_wrapper(path: Path) -> bool:
755
+ def _adapted_marker(path: Path) -> str | None:
277
756
  if path.is_symlink() or not path.is_dir():
278
- return False
279
- marker = path / ADAPTED_MARKER
280
- return not marker.is_symlink() and marker.is_file()
757
+ return None
758
+ for marker_name in sorted(ADAPTED_MARKERS):
759
+ marker = path / marker_name
760
+ if not marker.is_symlink() and marker.is_file():
761
+ return marker_name
762
+ return None
763
+
764
+
765
+ def _is_adapted_wrapper(path: Path) -> bool:
766
+ return _adapted_marker(path) is not None
281
767
 
282
768
 
283
769
  def _is_managed_entry(path: Path, skills_src: Path) -> bool:
@@ -301,18 +787,102 @@ def _sync_native_skill(skill_dir: Path, skills_dst: Path) -> str:
301
787
  return "linked"
302
788
 
303
789
 
304
- def _sync_adapted_skill(skill_dir: Path, skills_dst: Path) -> str:
790
+ def _sync_adapted_skill(
791
+ skill_dir: Path,
792
+ skills_dst: Path,
793
+ *,
794
+ platform: str = "codex",
795
+ marker_name: str = ADAPTED_MARKER,
796
+ ) -> str:
305
797
  target = skills_dst / skill_dir.name
306
798
  if target.is_symlink():
307
799
  if not _points_to(target, skill_dir):
308
800
  return "skipped"
309
- return _create_adapted_wrapper(skill_dir, target, replace_managed_link=True)
801
+ return _create_adapted_wrapper(
802
+ skill_dir,
803
+ target,
804
+ replace_managed_link=True,
805
+ platform=platform,
806
+ marker_name=marker_name,
807
+ )
310
808
  if target.exists() and not _is_adapted_wrapper(target):
311
809
  return "skipped"
312
810
  if not target.exists():
313
- return _create_adapted_wrapper(skill_dir, target)
811
+ return _create_adapted_wrapper(
812
+ skill_dir,
813
+ target,
814
+ platform=platform,
815
+ marker_name=marker_name,
816
+ )
817
+
818
+ if _adapted_marker(target) != marker_name:
819
+ return _transition_adapted_wrapper(
820
+ skill_dir,
821
+ target,
822
+ platform=platform,
823
+ marker_name=marker_name,
824
+ )
825
+
826
+ return _update_adapted_wrapper(
827
+ skill_dir,
828
+ target,
829
+ platform=platform,
830
+ marker_name=marker_name,
831
+ )
314
832
 
315
- return _update_adapted_wrapper(skill_dir, target)
833
+
834
+ def _transition_adapted_wrapper(
835
+ skill_dir: Path,
836
+ target: Path,
837
+ *,
838
+ platform: str,
839
+ marker_name: str,
840
+ ) -> str:
841
+ """Replace only managed wrapper files while preserving user additions."""
842
+ previous_marker_name = _validated_wrapper_marker(target)
843
+ if previous_marker_name is None:
844
+ return "skipped"
845
+ skill_output = target / "SKILL.md"
846
+ previous_marker = target / previous_marker_name
847
+ next_marker = target / marker_name
848
+ if skill_output.is_symlink() or not skill_output.is_file():
849
+ return "skipped"
850
+ if next_marker.exists() or next_marker.is_symlink():
851
+ return "skipped"
852
+
853
+ skill_state = _regular_file_state(skill_output)
854
+ marker_state = _regular_file_state(previous_marker)
855
+ next_skill = _stage_text(
856
+ skill_output,
857
+ _build_managed_skill_text(skill_dir / "SKILL.md", platform),
858
+ )
859
+ next_marker_file = _stage_text(
860
+ next_marker,
861
+ f"generated by ai-toolkit for {platform}\n",
862
+ )
863
+ try:
864
+ _assert_regular_file_state(skill_output, skill_state)
865
+ _assert_regular_file_state(previous_marker, marker_state)
866
+ if next_marker.exists() or next_marker.is_symlink():
867
+ raise RuntimeError(f"Managed skill marker appeared during sync: {next_marker}")
868
+ os.replace(next_skill, skill_output)
869
+ os.replace(next_marker_file, next_marker)
870
+ _assert_regular_file_state(previous_marker, marker_state)
871
+ previous_marker.unlink()
872
+ _sync_auxiliaries(skill_dir, target)
873
+ _fsync_directory(target)
874
+ except Exception:
875
+ _restore_regular_file(skill_output, skill_state)
876
+ if next_marker.is_file() and not next_marker.is_symlink():
877
+ expected = f"generated by ai-toolkit for {platform}\n".encode("utf-8")
878
+ if next_marker.read_bytes() == expected:
879
+ next_marker.unlink()
880
+ _restore_regular_file(previous_marker, marker_state)
881
+ raise
882
+ finally:
883
+ next_skill.unlink(missing_ok=True)
884
+ next_marker_file.unlink(missing_ok=True)
885
+ return "adapted"
316
886
 
317
887
 
318
888
  def _create_adapted_wrapper(
@@ -320,6 +890,8 @@ def _create_adapted_wrapper(
320
890
  target: Path,
321
891
  *,
322
892
  replace_managed_link: bool = False,
893
+ platform: str = "codex",
894
+ marker_name: str = ADAPTED_MARKER,
323
895
  ) -> str:
324
896
  """Build a complete sibling wrapper and expose it with one atomic rename."""
325
897
  staging = Path(tempfile.mkdtemp(
@@ -331,11 +903,11 @@ def _create_adapted_wrapper(
331
903
  try:
332
904
  _write_text_fsync(
333
905
  staging / "SKILL.md",
334
- build_codex_skill_text(skill_dir / "SKILL.md"),
906
+ _build_managed_skill_text(skill_dir / "SKILL.md", platform),
335
907
  )
336
908
  _write_text_fsync(
337
- staging / ADAPTED_MARKER,
338
- "generated by ai-toolkit for Codex\n",
909
+ staging / marker_name,
910
+ f"generated by ai-toolkit for {platform}\n",
339
911
  )
340
912
  _sync_auxiliaries(skill_dir, staging)
341
913
  _fsync_directory(staging)
@@ -358,15 +930,21 @@ def _create_adapted_wrapper(
358
930
  raise
359
931
 
360
932
 
361
- def _update_adapted_wrapper(skill_dir: Path, target: Path) -> str:
933
+ def _update_adapted_wrapper(
934
+ skill_dir: Path,
935
+ target: Path,
936
+ *,
937
+ platform: str = "codex",
938
+ marker_name: str = ADAPTED_MARKER,
939
+ ) -> str:
362
940
  """Atomically refresh SKILL.md while leaving the managed marker stable."""
363
941
  skill_output = target / "SKILL.md"
364
- marker = target / ADAPTED_MARKER
942
+ marker = target / marker_name
365
943
  if skill_output.is_symlink() or marker.is_symlink():
366
944
  return "skipped"
367
945
  temp_path = _stage_text(
368
946
  skill_output,
369
- build_codex_skill_text(skill_dir / "SKILL.md"),
947
+ _build_managed_skill_text(skill_dir / "SKILL.md", platform),
370
948
  )
371
949
  try:
372
950
  os.replace(temp_path, skill_output)
@@ -384,6 +962,44 @@ def _write_text_fsync(destination: Path, content: str) -> None:
384
962
  os.fsync(handle.fileno())
385
963
 
386
964
 
965
+ def _regular_file_state(path: Path) -> _RegularFileState:
966
+ if path.is_symlink() or not path.is_file():
967
+ raise RuntimeError(f"Managed file is not a regular non-symlink file: {path}")
968
+ metadata = path.stat()
969
+ return _RegularFileState(
970
+ data=path.read_bytes(),
971
+ signature=(
972
+ metadata.st_dev,
973
+ metadata.st_ino,
974
+ metadata.st_mode,
975
+ metadata.st_size,
976
+ metadata.st_mtime_ns,
977
+ ),
978
+ permissions=stat.S_IMODE(metadata.st_mode),
979
+ )
980
+
981
+
982
+ def _assert_regular_file_state(path: Path, expected: _RegularFileState) -> None:
983
+ actual = _regular_file_state(path)
984
+ if actual != expected:
985
+ raise RuntimeError(f"Managed file changed during skill sync: {path}")
986
+
987
+
988
+ def _restore_regular_file(path: Path, expected: _RegularFileState) -> None:
989
+ try:
990
+ if _regular_file_state(path).data == expected.data:
991
+ return
992
+ except RuntimeError:
993
+ if path.exists() or path.is_symlink():
994
+ raise
995
+ staged = _stage_bytes(path, expected.data)
996
+ try:
997
+ os.chmod(staged, expected.permissions)
998
+ os.replace(staged, path)
999
+ finally:
1000
+ staged.unlink(missing_ok=True)
1001
+
1002
+
387
1003
  def _fsync_directory(path: Path) -> None:
388
1004
  if os.name == "nt":
389
1005
  return
@@ -417,6 +1033,10 @@ def _remove_staged_wrapper(staging: Path) -> None:
417
1033
 
418
1034
 
419
1035
  def _stage_text(destination: Path, content: str) -> Path:
1036
+ return _stage_bytes(destination, content.encode("utf-8"))
1037
+
1038
+
1039
+ def _stage_bytes(destination: Path, content: bytes) -> Path:
420
1040
  fd, temp_name = tempfile.mkstemp(
421
1041
  dir=destination.parent,
422
1042
  prefix=f".{destination.name}.",
@@ -424,7 +1044,7 @@ def _stage_text(destination: Path, content: str) -> Path:
424
1044
  )
425
1045
  temp_path = Path(temp_name)
426
1046
  try:
427
- handle = os.fdopen(fd, "w", encoding="utf-8")
1047
+ handle = os.fdopen(fd, "wb")
428
1048
  fd = -1
429
1049
  with handle:
430
1050
  handle.write(content)
@@ -454,7 +1074,7 @@ def _sync_auxiliaries(skill_dir: Path, target: Path) -> None:
454
1074
 
455
1075
  source_root = skill_dir.resolve()
456
1076
  for destination in target.iterdir():
457
- if destination.name in expected | {"SKILL.md", ADAPTED_MARKER}:
1077
+ if destination.name in expected | {"SKILL.md", *ADAPTED_MARKERS}:
458
1078
  continue
459
1079
  if destination.is_symlink() and _is_relative_to(
460
1080
  _symlink_target(destination), source_root
@@ -466,8 +1086,11 @@ def _remove_managed_wrapper(path: Path, skills_src_resolved: Path) -> bool:
466
1086
  if not _is_adapted_wrapper(path):
467
1087
  return False
468
1088
  children = list(path.iterdir())
1089
+ marker_name = _adapted_marker(path)
1090
+ if marker_name is None:
1091
+ return False
469
1092
  for child in children:
470
- if child.name in {"SKILL.md", ADAPTED_MARKER}:
1093
+ if child.name in {"SKILL.md", marker_name}:
471
1094
  if child.is_symlink() or not child.is_file():
472
1095
  return False
473
1096
  continue
@@ -499,17 +1122,34 @@ def _parse_frontmatter(frontmatter_text: str) -> list[tuple[str, str]]:
499
1122
  return entries
500
1123
 
501
1124
 
1125
+ def _invocation_metadata_lines(frontmatter_text: str) -> list[str]:
1126
+ """Return exact canonical invocation lines, rejecting duplicate fields."""
1127
+ fields = ("user-invocable", "disable-model-invocation")
1128
+ lines: list[str] = []
1129
+ seen: set[str] = set()
1130
+ for line in frontmatter_text.splitlines():
1131
+ field = next((name for name in fields if line.startswith(f"{name}:")), None)
1132
+ if field is None:
1133
+ continue
1134
+ if field in seen:
1135
+ raise ValueError(f"Duplicate invocation metadata field: {field}")
1136
+ seen.add(field)
1137
+ lines.append(line)
1138
+ return lines
1139
+
1140
+
502
1141
  def _render_frontmatter(entries: list[tuple[str, str]]) -> str:
503
1142
  return "\n".join(f"{key}: {value}" for key, value in entries)
504
1143
 
505
1144
 
506
1145
  def _adapt_body(body: str, platform: str) -> str:
507
- label = _PLATFORM_LABELS[platform]
1146
+ label = _PLATFORM_LABELS.get(platform)
508
1147
  body = _replace_agent_calls(body, label)
509
- body = body.replace("Agent Teams", f"coordinated {label}-native subagents")
510
- body = body.replace("`Agent` tool", f"{label}-native subagents")
511
- body = body.replace("the `Agent` tool", f"{label}-native subagents")
512
- body = body.replace("Agent tool", f"{label}-native subagents")
1148
+ subagents = f"{label}-native subagents" if label else "available subagents"
1149
+ body = body.replace("Agent Teams", f"coordinated {subagents}")
1150
+ body = body.replace("`Agent` tool", subagents)
1151
+ body = body.replace("the `Agent` tool", subagents)
1152
+ body = body.replace("Agent tool", subagents)
513
1153
  for token, replacement in _semantic_replacements(platform).items():
514
1154
  body = body.replace(token, replacement)
515
1155
  if platform == "codex":
@@ -520,16 +1160,15 @@ def _adapt_body(body: str, platform: str) -> str:
520
1160
  return f"{_translation_note(platform).strip()}\n\n{body.strip()}\n"
521
1161
 
522
1162
 
523
- def _replace_agent_calls(body: str, label: str) -> str:
1163
+ def _replace_agent_calls(body: str, label: str | None) -> str:
524
1164
  """Replace balanced Agent calls without consuming surrounding markdown."""
525
1165
  rendered: list[str] = []
526
1166
  cursor = 0
527
1167
  while match := _AGENT_START_RE.search(body, cursor):
528
1168
  rendered.append(body[cursor:match.start()])
529
1169
  end = _balanced_call_end(body, match.end() - 1)
530
- rendered.append(
531
- f"Delegate this independent work to a suitable {label}-native subagent."
532
- )
1170
+ target = f"a suitable {label}-native subagent" if label else "an available subagent"
1171
+ rendered.append(f"Delegate this independent work to {target}.")
533
1172
  cursor = end
534
1173
  rendered.append(body[cursor:])
535
1174
  return "".join(rendered)