@softspark/ai-toolkit 4.24.0 → 4.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +35 -15
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/skills/hook-creator/SKILL.md +18 -4
- package/app/surface.json +4 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +67 -19
- package/bin/ai-toolkit.js +27 -3
- package/kb/reference/architecture-overview.md +13 -5
- package/kb/reference/claude-ecosystem-expansion-foundations.md +30 -5
- package/kb/reference/cli-reference.md +27 -2
- package/kb/reference/codex-cli-compatibility.md +101 -11
- package/kb/reference/global-install-model.md +10 -8
- package/kb/reference/hooks-catalog.md +41 -5
- package/kb/reference/mcp-editor-compatibility.md +3 -3
- package/kb/reference/mcp-templates.md +3 -3
- package/kb/reference/opencode-compatibility.md +53 -5
- package/kb/reference/supported-tools-registry.md +31 -26
- package/llms-full.txt +312 -73
- package/manifest.json +1 -1
- package/package.json +6 -2
- package/scripts/antigravity_plugin.py +570 -0
- package/scripts/codex_plugin.py +764 -0
- package/scripts/ecosystem_tools.json +92 -17
- package/scripts/generate_antigravity.py +16 -14
- package/scripts/generate_antigravity_agents.py +255 -0
- package/scripts/generate_antigravity_hooks.py +344 -0
- package/scripts/generate_cline_hooks.py +391 -0
- package/scripts/generate_cline_rules.py +210 -43
- package/scripts/generate_cline_skills.py +65 -2
- package/scripts/generate_codex_hooks.py +70 -8
- package/scripts/generate_gemini_agents.py +197 -0
- package/scripts/generate_gemini_hooks.py +24 -4
- package/scripts/generate_opencode_skills.py +544 -0
- package/scripts/inject_hook_cli.py +4 -26
- package/scripts/install.py +11 -10
- package/scripts/install_steps/ai_tools.py +209 -34
- package/scripts/mcp_editors.py +9 -1
- package/scripts/plugin.py +21 -0
- package/scripts/plugin_schema.py +8 -7
- package/scripts/secure_fs.py +35 -0
- package/scripts/uninstall.py +162 -18
- package/scripts/validate.py +42 -12
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
|
|
4
|
+
# Source: https://github.com/softspark/ai-toolkit
|
|
5
|
+
|
|
6
|
+
"""Generate native OpenCode Agent Skills directories.
|
|
7
|
+
|
|
8
|
+
OpenCode discovers project skills under ``.opencode/skills/<name>/SKILL.md``.
|
|
9
|
+
This generator copies each complete ai-toolkit skill so its scripts,
|
|
10
|
+
references, templates, and other relative resources remain available.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
|
+
from codex_skill_adapter import build_opencode_skill_text
|
|
23
|
+
from emission import skills_dir
|
|
24
|
+
from frontmatter import frontmatter_field
|
|
25
|
+
from secure_fs import SecureDestination, SecureTransaction, nearest_existing_root
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PORTABLE_FRONTMATTER = ("license", "compatibility")
|
|
29
|
+
SAFE_SKILL_NAME = re.compile(r"\A[a-z0-9]+(?:-[a-z0-9]+)*\Z")
|
|
30
|
+
MANAGED_MARKER = "<!-- ai-toolkit-managed: opencode-skill -->"
|
|
31
|
+
MANAGED_MANIFEST = ".ai-toolkit-managed-files"
|
|
32
|
+
IGNORED_PARTS = frozenset({"__pycache__", ".DS_Store"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PreparedSkill:
|
|
37
|
+
name: str
|
|
38
|
+
markdown: str
|
|
39
|
+
files: dict[Path, tuple[bytes, int]]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def managed_paths(self) -> set[Path]:
|
|
43
|
+
return {Path("SKILL.md"), Path(MANAGED_MANIFEST), *self.files}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _frontmatter_sections(skill_file: Path) -> dict[str, list[str]]:
|
|
47
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
48
|
+
if not text.startswith("---\n"):
|
|
49
|
+
return {}
|
|
50
|
+
parts = text.split("---", 2)
|
|
51
|
+
if len(parts) != 3:
|
|
52
|
+
return {}
|
|
53
|
+
sections: dict[str, list[str]] = {}
|
|
54
|
+
current: str | None = None
|
|
55
|
+
for line in parts[1].strip("\n").splitlines():
|
|
56
|
+
if line and not line[0].isspace() and ":" in line:
|
|
57
|
+
current = line.split(":", 1)[0].strip()
|
|
58
|
+
sections[current] = [line]
|
|
59
|
+
elif current is not None:
|
|
60
|
+
sections[current].append(line)
|
|
61
|
+
return sections
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _portable_body(skill_file: Path) -> str:
|
|
65
|
+
rendered = build_opencode_skill_text(skill_file)
|
|
66
|
+
if not rendered.startswith("---\n"):
|
|
67
|
+
return rendered.rstrip()
|
|
68
|
+
parts = rendered.split("---", 2)
|
|
69
|
+
return (parts[2] if len(parts) == 3 else rendered).lstrip("\n").rstrip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _render_skill(skill_file: Path) -> str:
|
|
73
|
+
name = frontmatter_field(skill_file, "name")
|
|
74
|
+
description = frontmatter_field(skill_file, "description")
|
|
75
|
+
sections = _frontmatter_sections(skill_file)
|
|
76
|
+
lines = [
|
|
77
|
+
"---",
|
|
78
|
+
f"name: {name}",
|
|
79
|
+
f"description: {json.dumps(description, ensure_ascii=False)}",
|
|
80
|
+
]
|
|
81
|
+
for key in PORTABLE_FRONTMATTER:
|
|
82
|
+
lines.extend(sections.get(key, []))
|
|
83
|
+
if frontmatter_field(skill_file, "user-invocable").lower() == "false":
|
|
84
|
+
lines.append("slash: false")
|
|
85
|
+
metadata = list(sections.get("metadata", []))
|
|
86
|
+
disable_model = (
|
|
87
|
+
frontmatter_field(skill_file, "disable-model-invocation").lower()
|
|
88
|
+
== "true"
|
|
89
|
+
)
|
|
90
|
+
if metadata or disable_model:
|
|
91
|
+
if not metadata or metadata == ["metadata: {}"]:
|
|
92
|
+
metadata = ["metadata:"]
|
|
93
|
+
if disable_model and not any(
|
|
94
|
+
line.strip().startswith("opencode/autoinvoke:") for line in metadata[1:]
|
|
95
|
+
):
|
|
96
|
+
metadata.append(" opencode/autoinvoke: false")
|
|
97
|
+
lines.extend(metadata)
|
|
98
|
+
lines.extend(("---", "", MANAGED_MARKER, "", _portable_body(skill_file), ""))
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_managed_skill(path: Path) -> bool:
|
|
103
|
+
skill_file = path / "SKILL.md"
|
|
104
|
+
if path.is_symlink() or not path.is_dir() or skill_file.is_symlink():
|
|
105
|
+
return False
|
|
106
|
+
try:
|
|
107
|
+
return MANAGED_MARKER in skill_file.read_text(encoding="utf-8")
|
|
108
|
+
except (OSError, UnicodeError):
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _managed_paths(path: Path) -> set[Path] | None:
|
|
113
|
+
manifest = path / MANAGED_MANIFEST
|
|
114
|
+
if manifest.is_symlink() or not manifest.is_file():
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
value = json.loads(manifest.read_text(encoding="utf-8"))
|
|
118
|
+
except (OSError, json.JSONDecodeError):
|
|
119
|
+
return None
|
|
120
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
121
|
+
return None
|
|
122
|
+
paths = {Path(item) for item in value}
|
|
123
|
+
if any(item.is_absolute() or ".." in item.parts for item in paths):
|
|
124
|
+
return None
|
|
125
|
+
return paths | {Path(MANAGED_MANIFEST)}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _managed_directories(managed: set[Path]) -> set[Path]:
|
|
129
|
+
directories: set[Path] = set()
|
|
130
|
+
for relative in managed:
|
|
131
|
+
parent = relative.parent
|
|
132
|
+
while parent != Path("."):
|
|
133
|
+
directories.add(parent)
|
|
134
|
+
parent = parent.parent
|
|
135
|
+
return directories
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _has_user_extras(path: Path) -> bool:
|
|
139
|
+
managed = _managed_paths(path)
|
|
140
|
+
if managed is None:
|
|
141
|
+
return True
|
|
142
|
+
managed_directories = _managed_directories(managed)
|
|
143
|
+
for item in path.rglob("*"):
|
|
144
|
+
if item.is_symlink():
|
|
145
|
+
return True
|
|
146
|
+
relative = item.relative_to(path)
|
|
147
|
+
if item.is_dir() and relative not in managed_directories:
|
|
148
|
+
return True
|
|
149
|
+
if item.is_file() and relative not in managed:
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _source_skills(source_root: Path) -> list[Path]:
|
|
155
|
+
return [
|
|
156
|
+
entry
|
|
157
|
+
for entry in sorted(source_root.iterdir())
|
|
158
|
+
if entry.is_dir()
|
|
159
|
+
and not entry.name.startswith((".", "_"))
|
|
160
|
+
and (entry / "SKILL.md").is_file()
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_ignored(relative: Path) -> bool:
|
|
165
|
+
return bool(IGNORED_PARTS.intersection(relative.parts)) or relative.suffix in {
|
|
166
|
+
".pyc",
|
|
167
|
+
".pyo",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _source_files(source: Path) -> dict[Path, tuple[bytes, int]]:
|
|
172
|
+
files: dict[Path, tuple[bytes, int]] = {}
|
|
173
|
+
for path in sorted(source.rglob("*")):
|
|
174
|
+
relative = path.relative_to(source)
|
|
175
|
+
if _is_ignored(relative) or path.is_dir():
|
|
176
|
+
continue
|
|
177
|
+
if not path.is_file():
|
|
178
|
+
raise RuntimeError(f"Unsupported OpenCode skill source: {path}")
|
|
179
|
+
if relative == Path("SKILL.md"):
|
|
180
|
+
continue
|
|
181
|
+
mode = stat.S_IMODE(path.stat().st_mode) or 0o644
|
|
182
|
+
files[relative] = (path.read_bytes(), mode)
|
|
183
|
+
return files
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _prepare_sources(source_root: Path) -> list[PreparedSkill]:
|
|
187
|
+
"""Validate every source and render all SKILL.md files before writes."""
|
|
188
|
+
if source_root.is_symlink() or not source_root.is_dir():
|
|
189
|
+
raise RuntimeError(f"Invalid OpenCode skill source root: {source_root}")
|
|
190
|
+
for path in sorted(source_root.rglob("*")):
|
|
191
|
+
if path.is_symlink():
|
|
192
|
+
raise RuntimeError(f"Refusing symlinked OpenCode skill source: {path}")
|
|
193
|
+
if not path.is_dir() and not path.is_file():
|
|
194
|
+
raise RuntimeError(f"Unsupported OpenCode skill source: {path}")
|
|
195
|
+
|
|
196
|
+
prepared: list[PreparedSkill] = []
|
|
197
|
+
names: set[str] = set()
|
|
198
|
+
for source in _source_skills(source_root):
|
|
199
|
+
skill_file = source / "SKILL.md"
|
|
200
|
+
name = frontmatter_field(skill_file, "name")
|
|
201
|
+
description = frontmatter_field(skill_file, "description")
|
|
202
|
+
if len(name) > 64 or not SAFE_SKILL_NAME.fullmatch(name):
|
|
203
|
+
raise ValueError(f"Invalid OpenCode skill name: {skill_file}")
|
|
204
|
+
if not description or len(description) > 1024:
|
|
205
|
+
raise ValueError(f"Invalid OpenCode skill description: {skill_file}")
|
|
206
|
+
if name in names:
|
|
207
|
+
raise ValueError(f"Duplicate OpenCode skill name: {name}")
|
|
208
|
+
names.add(name)
|
|
209
|
+
prepared.append(
|
|
210
|
+
PreparedSkill(
|
|
211
|
+
name=name,
|
|
212
|
+
markdown=_render_skill(skill_file),
|
|
213
|
+
files=_source_files(source),
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
return prepared
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _assert_destination_roots(
|
|
220
|
+
target_dir: Path, base: Path, destination_root: Path
|
|
221
|
+
) -> None:
|
|
222
|
+
for path in (target_dir, base, destination_root):
|
|
223
|
+
if path.is_symlink():
|
|
224
|
+
raise RuntimeError(f"Refusing symlinked OpenCode destination: {path}")
|
|
225
|
+
if path.exists() and not path.is_dir():
|
|
226
|
+
raise RuntimeError(f"OpenCode destination is not a directory: {path}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _destination_transaction(
|
|
230
|
+
target_dir: Path,
|
|
231
|
+
base: Path,
|
|
232
|
+
destination_root: Path,
|
|
233
|
+
) -> tuple[SecureTransaction, SecureDestination, SecureDestination, Path]:
|
|
234
|
+
"""Pin the base and skills roots for the complete operation."""
|
|
235
|
+
trusted_root = nearest_existing_root(target_dir)
|
|
236
|
+
base_probe = SecureDestination(
|
|
237
|
+
base / MANAGED_MANIFEST,
|
|
238
|
+
trusted_root,
|
|
239
|
+
"OpenCode configuration ancestry",
|
|
240
|
+
)
|
|
241
|
+
skills_probe = SecureDestination(
|
|
242
|
+
destination_root / MANAGED_MANIFEST,
|
|
243
|
+
trusted_root,
|
|
244
|
+
"OpenCode skills ancestry",
|
|
245
|
+
)
|
|
246
|
+
return (
|
|
247
|
+
SecureTransaction([base_probe, skills_probe]),
|
|
248
|
+
base_probe,
|
|
249
|
+
skills_probe,
|
|
250
|
+
trusted_root,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _user_extra_files(path: Path, managed: set[Path]) -> set[Path]:
|
|
255
|
+
files: set[Path] = set()
|
|
256
|
+
for item in sorted(path.rglob("*")):
|
|
257
|
+
relative = item.relative_to(path)
|
|
258
|
+
if item.is_symlink():
|
|
259
|
+
raise RuntimeError(f"Refusing symlinked OpenCode destination: {item}")
|
|
260
|
+
if item.is_dir():
|
|
261
|
+
continue
|
|
262
|
+
if not item.is_file():
|
|
263
|
+
raise RuntimeError(f"Unsupported OpenCode destination entry: {item}")
|
|
264
|
+
if relative in managed:
|
|
265
|
+
continue
|
|
266
|
+
files.add(relative)
|
|
267
|
+
return files
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _user_skill_names(destination_root: Path) -> set[str]:
|
|
271
|
+
if not destination_root.is_dir():
|
|
272
|
+
return set()
|
|
273
|
+
names: set[str] = set()
|
|
274
|
+
for path in sorted(destination_root.iterdir()):
|
|
275
|
+
if path.is_symlink():
|
|
276
|
+
raise RuntimeError(f"Refusing symlinked OpenCode skill destination: {path}")
|
|
277
|
+
if not path.is_dir() or _is_managed_skill(path):
|
|
278
|
+
continue
|
|
279
|
+
skill_file = path / "SKILL.md"
|
|
280
|
+
if skill_file.is_symlink():
|
|
281
|
+
raise RuntimeError(f"Refusing symlinked OpenCode skill destination: {skill_file}")
|
|
282
|
+
if not skill_file.is_file():
|
|
283
|
+
continue
|
|
284
|
+
name = frontmatter_field(skill_file, "name")
|
|
285
|
+
if name:
|
|
286
|
+
names.add(name)
|
|
287
|
+
return names
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _skill_writes(
|
|
291
|
+
destination_root: Path,
|
|
292
|
+
skill: PreparedSkill,
|
|
293
|
+
) -> dict[Path, tuple[bytes, int]]:
|
|
294
|
+
skill_root = destination_root / skill.name
|
|
295
|
+
writes = {
|
|
296
|
+
skill_root / relative: value
|
|
297
|
+
for relative, value in skill.files.items()
|
|
298
|
+
}
|
|
299
|
+
writes[skill_root / "SKILL.md"] = (skill.markdown.encode(), 0o644)
|
|
300
|
+
manifest = (
|
|
301
|
+
json.dumps(
|
|
302
|
+
sorted(path.as_posix() for path in skill.managed_paths),
|
|
303
|
+
indent=2,
|
|
304
|
+
)
|
|
305
|
+
+ "\n"
|
|
306
|
+
).encode()
|
|
307
|
+
writes[skill_root / MANAGED_MANIFEST] = (manifest, 0o644)
|
|
308
|
+
return writes
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _secure_file_mutations(
|
|
312
|
+
writes: dict[Path, tuple[bytes, int]],
|
|
313
|
+
removals: set[Path],
|
|
314
|
+
*,
|
|
315
|
+
trusted_root: Path,
|
|
316
|
+
ancestry: SecureTransaction,
|
|
317
|
+
skills_probe: SecureDestination,
|
|
318
|
+
prune: set[Path],
|
|
319
|
+
) -> None:
|
|
320
|
+
mutation_paths = set(writes) | removals
|
|
321
|
+
destinations = {
|
|
322
|
+
path: SecureDestination(path, trusted_root, f"OpenCode skill file {path.name}")
|
|
323
|
+
for path in mutation_paths
|
|
324
|
+
}
|
|
325
|
+
transaction = SecureTransaction(list(destinations.values()))
|
|
326
|
+
try:
|
|
327
|
+
transaction.materialize_parents()
|
|
328
|
+
for path, (content, mode) in sorted(writes.items()):
|
|
329
|
+
transaction.atomic_write(destinations[path], content, mode)
|
|
330
|
+
for path in sorted(removals - set(writes), reverse=True):
|
|
331
|
+
transaction.unlink(destinations[path])
|
|
332
|
+
for relative in sorted(prune, key=lambda item: len(item.parts), reverse=True):
|
|
333
|
+
ancestry.rmdir_empty(skills_probe, relative)
|
|
334
|
+
except BaseException as error:
|
|
335
|
+
try:
|
|
336
|
+
transaction.rollback()
|
|
337
|
+
except Exception as rollback_error:
|
|
338
|
+
raise RuntimeError(
|
|
339
|
+
"OpenCode skill mutation failed and rollback was incomplete: "
|
|
340
|
+
f"{rollback_error}"
|
|
341
|
+
) from error
|
|
342
|
+
raise
|
|
343
|
+
finally:
|
|
344
|
+
transaction.close()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _rollback_ancestry(
|
|
348
|
+
transaction: SecureTransaction,
|
|
349
|
+
error: BaseException,
|
|
350
|
+
) -> None:
|
|
351
|
+
try:
|
|
352
|
+
transaction.rollback()
|
|
353
|
+
except Exception as rollback_error:
|
|
354
|
+
raise RuntimeError(
|
|
355
|
+
"OpenCode ancestry rollback was incomplete: "
|
|
356
|
+
f"{rollback_error}"
|
|
357
|
+
) from error
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def cleanup(
|
|
361
|
+
target_dir: Path,
|
|
362
|
+
config_root: Path | None = None,
|
|
363
|
+
) -> tuple[int, int]:
|
|
364
|
+
"""Remove native managed skills and return ``(removed, preserved)``."""
|
|
365
|
+
base = config_root if config_root is not None else target_dir / ".opencode"
|
|
366
|
+
destination_root = base / "skills"
|
|
367
|
+
ancestry, base_probe, skills_probe, trusted_root = _destination_transaction(
|
|
368
|
+
target_dir,
|
|
369
|
+
base,
|
|
370
|
+
destination_root,
|
|
371
|
+
)
|
|
372
|
+
try:
|
|
373
|
+
_assert_destination_roots(target_dir, base, destination_root)
|
|
374
|
+
if not destination_root.is_dir():
|
|
375
|
+
return 0, 0
|
|
376
|
+
ancestry.materialize_parents()
|
|
377
|
+
removals: set[Path] = set()
|
|
378
|
+
prune: set[Path] = set()
|
|
379
|
+
removed = 0
|
|
380
|
+
preserved = 0
|
|
381
|
+
for path in sorted(destination_root.iterdir()):
|
|
382
|
+
managed = _managed_paths(path)
|
|
383
|
+
if managed is None or not _is_managed_skill(path):
|
|
384
|
+
if path.is_dir() or path.is_symlink():
|
|
385
|
+
preserved += 1
|
|
386
|
+
continue
|
|
387
|
+
if any(item.is_symlink() for item in path.rglob("*")):
|
|
388
|
+
preserved += 1
|
|
389
|
+
continue
|
|
390
|
+
removed += 1
|
|
391
|
+
removals.update(path / relative for relative in managed)
|
|
392
|
+
prune.update(
|
|
393
|
+
Path(path.name) / relative
|
|
394
|
+
for relative in _managed_directories(managed)
|
|
395
|
+
)
|
|
396
|
+
prune.add(Path(path.name))
|
|
397
|
+
if removals:
|
|
398
|
+
_secure_file_mutations(
|
|
399
|
+
{},
|
|
400
|
+
removals,
|
|
401
|
+
trusted_root=trusted_root,
|
|
402
|
+
ancestry=ancestry,
|
|
403
|
+
skills_probe=skills_probe,
|
|
404
|
+
prune=prune,
|
|
405
|
+
)
|
|
406
|
+
ancestry.rmdir_empty(base_probe, Path("skills"))
|
|
407
|
+
return removed, preserved
|
|
408
|
+
except BaseException as error:
|
|
409
|
+
_rollback_ancestry(ancestry, error)
|
|
410
|
+
raise
|
|
411
|
+
finally:
|
|
412
|
+
ancestry.close()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def discover(
|
|
416
|
+
target_dir: Path,
|
|
417
|
+
config_root: Path | None = None,
|
|
418
|
+
) -> int:
|
|
419
|
+
"""Return the number of native toolkit-managed OpenCode skills."""
|
|
420
|
+
base = config_root if config_root is not None else target_dir / ".opencode"
|
|
421
|
+
destination_root = base / "skills"
|
|
422
|
+
ancestry, _, _, _ = _destination_transaction(
|
|
423
|
+
target_dir,
|
|
424
|
+
base,
|
|
425
|
+
destination_root,
|
|
426
|
+
)
|
|
427
|
+
try:
|
|
428
|
+
_assert_destination_roots(target_dir, base, destination_root)
|
|
429
|
+
if not destination_root.is_dir():
|
|
430
|
+
return 0
|
|
431
|
+
return sum(
|
|
432
|
+
1 for path in destination_root.iterdir() if _is_managed_skill(path)
|
|
433
|
+
)
|
|
434
|
+
finally:
|
|
435
|
+
ancestry.close()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def generate(
|
|
439
|
+
target_dir: Path,
|
|
440
|
+
config_root: Path | None = None,
|
|
441
|
+
source_root: Path = skills_dir,
|
|
442
|
+
) -> tuple[int, int, int]:
|
|
443
|
+
"""Copy skills and return ``(written, removed_stale, preserved)``."""
|
|
444
|
+
prepared = _prepare_sources(source_root)
|
|
445
|
+
base = config_root if config_root is not None else target_dir / ".opencode"
|
|
446
|
+
destination_root = base / "skills"
|
|
447
|
+
ancestry, _, skills_probe, trusted_root = _destination_transaction(
|
|
448
|
+
target_dir,
|
|
449
|
+
base,
|
|
450
|
+
destination_root,
|
|
451
|
+
)
|
|
452
|
+
try:
|
|
453
|
+
ancestry.materialize_parents()
|
|
454
|
+
_assert_destination_roots(target_dir, base, destination_root)
|
|
455
|
+
user_names = _user_skill_names(destination_root)
|
|
456
|
+
preserved = 0
|
|
457
|
+
expected: set[str] = set()
|
|
458
|
+
writes: dict[Path, tuple[bytes, int]] = {}
|
|
459
|
+
removals: set[Path] = set()
|
|
460
|
+
prune: set[Path] = set()
|
|
461
|
+
for skill in prepared:
|
|
462
|
+
destination = destination_root / skill.name
|
|
463
|
+
if skill.name in user_names:
|
|
464
|
+
preserved += 1
|
|
465
|
+
continue
|
|
466
|
+
if (
|
|
467
|
+
destination.exists() or destination.is_symlink()
|
|
468
|
+
) and not _is_managed_skill(destination):
|
|
469
|
+
preserved += 1
|
|
470
|
+
continue
|
|
471
|
+
managed: set[Path] = set()
|
|
472
|
+
extras: set[Path] = set()
|
|
473
|
+
if destination.exists():
|
|
474
|
+
managed = _managed_paths(destination)
|
|
475
|
+
if managed is None:
|
|
476
|
+
preserved += 1
|
|
477
|
+
continue
|
|
478
|
+
extras = _user_extra_files(destination, managed)
|
|
479
|
+
collisions = skill.managed_paths.intersection(extras)
|
|
480
|
+
if collisions:
|
|
481
|
+
joined = ", ".join(sorted(path.as_posix() for path in collisions))
|
|
482
|
+
raise RuntimeError(
|
|
483
|
+
"OpenCode skill update would overwrite user-owned files: "
|
|
484
|
+
f"{joined}"
|
|
485
|
+
)
|
|
486
|
+
writes.update(_skill_writes(destination_root, skill))
|
|
487
|
+
removals.update(
|
|
488
|
+
destination / relative
|
|
489
|
+
for relative in managed - skill.managed_paths
|
|
490
|
+
)
|
|
491
|
+
prune.update(
|
|
492
|
+
Path(skill.name) / relative
|
|
493
|
+
for relative in _managed_directories(managed)
|
|
494
|
+
)
|
|
495
|
+
expected.add(skill.name)
|
|
496
|
+
|
|
497
|
+
stale = 0
|
|
498
|
+
for path in sorted(destination_root.iterdir()):
|
|
499
|
+
if path.name.startswith(".ai-toolkit-") or path.name in expected:
|
|
500
|
+
continue
|
|
501
|
+
if not _is_managed_skill(path):
|
|
502
|
+
continue
|
|
503
|
+
if _has_user_extras(path):
|
|
504
|
+
preserved += 1
|
|
505
|
+
continue
|
|
506
|
+
managed = _managed_paths(path)
|
|
507
|
+
if managed is None:
|
|
508
|
+
preserved += 1
|
|
509
|
+
continue
|
|
510
|
+
stale += 1
|
|
511
|
+
removals.update(path / relative for relative in managed)
|
|
512
|
+
prune.update(
|
|
513
|
+
Path(path.name) / relative
|
|
514
|
+
for relative in _managed_directories(managed)
|
|
515
|
+
)
|
|
516
|
+
prune.add(Path(path.name))
|
|
517
|
+
|
|
518
|
+
_secure_file_mutations(
|
|
519
|
+
writes,
|
|
520
|
+
removals,
|
|
521
|
+
trusted_root=trusted_root,
|
|
522
|
+
ancestry=ancestry,
|
|
523
|
+
skills_probe=skills_probe,
|
|
524
|
+
prune=prune,
|
|
525
|
+
)
|
|
526
|
+
return len(expected), stale, preserved
|
|
527
|
+
except BaseException as error:
|
|
528
|
+
_rollback_ancestry(ancestry, error)
|
|
529
|
+
raise
|
|
530
|
+
finally:
|
|
531
|
+
ancestry.close()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def main() -> None:
|
|
535
|
+
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
536
|
+
written, removed, preserved = generate(target)
|
|
537
|
+
print(
|
|
538
|
+
f"Generated: .opencode/skills/ ({written} skills, "
|
|
539
|
+
f"{removed} stale removed, {preserved} preserved)"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
if __name__ == "__main__":
|
|
544
|
+
main()
|
|
@@ -65,38 +65,16 @@ from secure_fs import (
|
|
|
65
65
|
nearest_existing_root,
|
|
66
66
|
run_secure_transaction,
|
|
67
67
|
)
|
|
68
|
+
from generate_codex_hooks import (
|
|
69
|
+
HANDLER_KEYS as CODEX_NATIVE_HANDLER_KEYS,
|
|
70
|
+
SUPPORTED_EVENTS as CODEX_EVENTS,
|
|
71
|
+
)
|
|
68
72
|
|
|
69
73
|
# Protected source tag -- this CLI must never touch ai-toolkit's own entries.
|
|
70
74
|
PROTECTED_SOURCE = "ai-toolkit"
|
|
71
75
|
|
|
72
|
-
# Codex CLI's native HookEventName enum defines these 10 events. External
|
|
73
|
-
# command hooks can target all of them even though ai-toolkit's base bundle does
|
|
74
|
-
# not currently ship a PostCompact handler.
|
|
75
|
-
CODEX_EVENTS = {
|
|
76
|
-
"SessionStart",
|
|
77
|
-
"PreToolUse",
|
|
78
|
-
"PostToolUse",
|
|
79
|
-
"PermissionRequest",
|
|
80
|
-
"PostCompact",
|
|
81
|
-
"UserPromptSubmit",
|
|
82
|
-
"SubagentStart",
|
|
83
|
-
"SubagentStop",
|
|
84
|
-
"PreCompact",
|
|
85
|
-
"Stop",
|
|
86
|
-
}
|
|
87
|
-
|
|
88
76
|
CODEX_OWNER_PREFIX = "ai-toolkit-external"
|
|
89
77
|
CODEX_NATIVE_GROUP_KEYS = frozenset({"matcher", "hooks"})
|
|
90
|
-
CODEX_NATIVE_HANDLER_KEYS = frozenset(
|
|
91
|
-
{
|
|
92
|
-
"type",
|
|
93
|
-
"command",
|
|
94
|
-
"commandWindows",
|
|
95
|
-
"timeout",
|
|
96
|
-
"statusMessage",
|
|
97
|
-
"async",
|
|
98
|
-
}
|
|
99
|
-
)
|
|
100
78
|
CODEX_OWNER_PATTERN = re.compile(
|
|
101
79
|
r"(?:^|\s)AI_TOOLKIT_HOOK_OWNER=(?P<owner>[a-z0-9][a-z0-9-]*)(?=\s|$)"
|
|
102
80
|
)
|
package/scripts/install.py
CHANGED
|
@@ -18,7 +18,7 @@ Claude Code (~/.claude/):
|
|
|
18
18
|
Other tools (global config locations):
|
|
19
19
|
- Windsurf: ~/.codeium/windsurf/memories/global_rules.md + ~/.codeium/windsurf/skills/
|
|
20
20
|
- Gemini: ~/.gemini/GEMINI.md
|
|
21
|
-
- Cline: ~/Documents/Cline/Rules/
|
|
21
|
+
- Cline: ~/.cline/{rules,hooks,skills}/ + ~/Documents/Cline/{Rules,Hooks}/
|
|
22
22
|
- Roo Code: ~/.roo/rules/
|
|
23
23
|
- Aider: ~/.aider.conf.yml (created only if absent)
|
|
24
24
|
- Augment: ~/.augment/rules/ai-toolkit.md
|
|
@@ -59,10 +59,7 @@ from install_steps.hooks import cleanup_retired_output_filter, install_hooks
|
|
|
59
59
|
from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks, refresh_url_mcp
|
|
60
60
|
from install_steps.ai_tools import install_ai_tools, install_local_project, run_script
|
|
61
61
|
from install_steps.install_state import (
|
|
62
|
-
load_state,
|
|
63
62
|
record_install,
|
|
64
|
-
get_installed_modules,
|
|
65
|
-
get_installed_profile,
|
|
66
63
|
get_global_editors,
|
|
67
64
|
record_global_editors,
|
|
68
65
|
print_status,
|
|
@@ -347,10 +344,10 @@ def validate_args(cfg: dict) -> None:
|
|
|
347
344
|
|
|
348
345
|
# Validate --lang
|
|
349
346
|
if cfg["lang"]:
|
|
350
|
-
for
|
|
351
|
-
|
|
352
|
-
if
|
|
353
|
-
errors.append(f"Unknown language: '{
|
|
347
|
+
for language in cfg["lang"].split(","):
|
|
348
|
+
language = language.strip()
|
|
349
|
+
if language and language.lower() not in VALID_LANGS:
|
|
350
|
+
errors.append(f"Unknown language: '{language}' (valid: {', '.join(sorted(VALID_LANGS - {'c++', 'c#', 'cs', 'go', 'common'}))})")
|
|
354
351
|
|
|
355
352
|
if errors:
|
|
356
353
|
for e in errors:
|
|
@@ -688,8 +685,12 @@ def main() -> None:
|
|
|
688
685
|
# --lang <list> → merge into --modules as rules-<lang> entries
|
|
689
686
|
_LANG_ALIASES = {"go": "golang", "c++": "cpp", "c#": "csharp", "cs": "csharp"}
|
|
690
687
|
if lang_arg:
|
|
691
|
-
langs = [
|
|
692
|
-
|
|
688
|
+
langs = [
|
|
689
|
+
_LANG_ALIASES.get(language.strip(), language.strip())
|
|
690
|
+
for language in lang_arg.split(",")
|
|
691
|
+
if language.strip()
|
|
692
|
+
]
|
|
693
|
+
lang_modules = ",".join(f"rules-{language}" for language in langs)
|
|
693
694
|
modules_arg = f"{modules_arg},{lang_modules}" if modules_arg else lang_modules
|
|
694
695
|
auto_detect = False # explicit --lang overrides auto-detect
|
|
695
696
|
if not local:
|