@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +35 -15
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/skills/hook-creator/SKILL.md +18 -4
  5. package/app/surface.json +4 -0
  6. package/benchmarks/ecosystem-doctor-snapshot.json +67 -19
  7. package/bin/ai-toolkit.js +27 -3
  8. package/kb/reference/architecture-overview.md +13 -5
  9. package/kb/reference/claude-ecosystem-expansion-foundations.md +30 -5
  10. package/kb/reference/cli-reference.md +27 -2
  11. package/kb/reference/codex-cli-compatibility.md +101 -11
  12. package/kb/reference/global-install-model.md +10 -8
  13. package/kb/reference/hooks-catalog.md +41 -5
  14. package/kb/reference/mcp-editor-compatibility.md +3 -3
  15. package/kb/reference/mcp-templates.md +3 -3
  16. package/kb/reference/opencode-compatibility.md +53 -5
  17. package/kb/reference/supported-tools-registry.md +31 -26
  18. package/llms-full.txt +312 -73
  19. package/manifest.json +1 -1
  20. package/package.json +6 -2
  21. package/scripts/antigravity_plugin.py +570 -0
  22. package/scripts/codex_plugin.py +764 -0
  23. package/scripts/ecosystem_tools.json +92 -17
  24. package/scripts/generate_antigravity.py +16 -14
  25. package/scripts/generate_antigravity_agents.py +255 -0
  26. package/scripts/generate_antigravity_hooks.py +344 -0
  27. package/scripts/generate_cline_hooks.py +391 -0
  28. package/scripts/generate_cline_rules.py +210 -43
  29. package/scripts/generate_cline_skills.py +65 -2
  30. package/scripts/generate_codex_hooks.py +70 -8
  31. package/scripts/generate_gemini_agents.py +197 -0
  32. package/scripts/generate_gemini_hooks.py +24 -4
  33. package/scripts/generate_opencode_skills.py +544 -0
  34. package/scripts/inject_hook_cli.py +4 -26
  35. package/scripts/install.py +11 -10
  36. package/scripts/install_steps/ai_tools.py +209 -34
  37. package/scripts/mcp_editors.py +9 -1
  38. package/scripts/plugin.py +21 -0
  39. package/scripts/plugin_schema.py +8 -7
  40. package/scripts/secure_fs.py +35 -0
  41. package/scripts/uninstall.py +162 -18
  42. 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
  )
@@ -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/ + ~/.cline/skills/
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 l in cfg["lang"].split(","):
351
- l = l.strip()
352
- if l and l.lower() not in VALID_LANGS:
353
- errors.append(f"Unknown language: '{l}' (valid: {', '.join(sorted(VALID_LANGS - {'c++', 'c#', 'cs', 'go', 'common'}))})")
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 = [_LANG_ALIASES.get(l.strip(), l.strip()) for l in lang_arg.split(",") if l.strip()]
692
- lang_modules = ",".join(f"rules-{l}" for l in langs)
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: