@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.
- package/CHANGELOG.md +82 -0
- package/README.md +44 -18
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/mcp-templates/README.md +7 -2
- package/app/mcp-templates/rag-mcp-legal.json +11 -0
- package/app/mcp-templates/rag-mcp.json +11 -0
- package/app/surface.json +1 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
- package/bin/ai-toolkit.js +8 -0
- package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
- package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
- package/kb/procedures/ecosystem-sync-sop.md +7 -5
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/release-verification-sop.md +35 -5
- package/kb/reference/architecture-overview.md +24 -5
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/dsh-compatibility.md +183 -0
- package/kb/reference/manifest-install.md +112 -5
- package/kb/reference/mcp-templates.md +11 -4
- package/kb/reference/plugin-pack-conventions.md +35 -18
- package/kb/reference/supported-tools-registry.md +30 -6
- package/llms-full.txt +1110 -50
- package/llms.txt +3 -0
- package/manifest.json +2 -2
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +673 -34
- package/scripts/config_resolver.py +80 -14
- package/scripts/doctor.py +98 -20
- package/scripts/ecosystem_tools.json +51 -1
- package/scripts/generate_codex_skills.py +22 -20
- package/scripts/install.py +30 -13
- package/scripts/install_steps/ai_tools.py +97 -33
- package/scripts/install_steps/dsh.py +5063 -0
- package/scripts/install_steps/install_state.py +1645 -57
- package/scripts/mcp_editors.py +5 -2
- package/scripts/plugin.py +2495 -163
- package/scripts/plugin_mcp.py +279 -0
- package/scripts/plugin_rules.py +389 -0
- package/scripts/plugin_schema.py +139 -23
- package/scripts/uninstall.py +47 -4
- 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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
183
|
-
(
|
|
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
|
|
755
|
+
def _adapted_marker(path: Path) -> str | None:
|
|
277
756
|
if path.is_symlink() or not path.is_dir():
|
|
278
|
-
return
|
|
279
|
-
|
|
280
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
906
|
+
_build_managed_skill_text(skill_dir / "SKILL.md", platform),
|
|
335
907
|
)
|
|
336
908
|
_write_text_fsync(
|
|
337
|
-
staging /
|
|
338
|
-
"generated by ai-toolkit for
|
|
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(
|
|
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 /
|
|
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
|
-
|
|
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, "
|
|
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",
|
|
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",
|
|
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
|
|
1146
|
+
label = _PLATFORM_LABELS.get(platform)
|
|
508
1147
|
body = _replace_agent_calls(body, label)
|
|
509
|
-
|
|
510
|
-
body = body.replace("
|
|
511
|
-
body = body.replace("
|
|
512
|
-
body = body.replace("Agent tool",
|
|
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
|
-
|
|
531
|
-
|
|
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)
|