@softspark/ai-toolkit 2.12.0 → 3.0.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 (41) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +18 -8
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/skills/hook-creator/SKILL.md +84 -8
  5. package/app/skills/skill-creator/SKILL.md +8 -4
  6. package/benchmarks/ecosystem-doctor-snapshot.json +395 -0
  7. package/kb/history/completed/deep-coverage-v3-20260423.md +160 -0
  8. package/kb/history/completed/ecosystem-deep-sweep-20260423.md +273 -0
  9. package/kb/procedures/ecosystem-sync-sop.md +255 -0
  10. package/kb/procedures/maintenance-sop.md +13 -2
  11. package/kb/procedures/release-preparation-sop.md +83 -10
  12. package/kb/reference/global-install-model.md +15 -2
  13. package/kb/reference/supported-tools-registry.md +229 -0
  14. package/llms-full.txt +1052 -14
  15. package/llms.txt +4 -0
  16. package/manifest.json +1 -1
  17. package/package.json +4 -1
  18. package/scripts/ecosystem_doctor.py +348 -0
  19. package/scripts/ecosystem_tools.json +500 -0
  20. package/scripts/generate_aider_conf.py +26 -1
  21. package/scripts/generate_antigravity.py +77 -8
  22. package/scripts/generate_augment_agents.py +161 -0
  23. package/scripts/generate_augment_commands.py +160 -0
  24. package/scripts/generate_augment_hooks.py +162 -0
  25. package/scripts/generate_augment_skills.py +98 -0
  26. package/scripts/generate_cline_rules.py +96 -9
  27. package/scripts/generate_codex_hooks.py +13 -2
  28. package/scripts/generate_codex_skills.py +195 -0
  29. package/scripts/generate_copilot.py +296 -18
  30. package/scripts/generate_cursor_agents.py +144 -0
  31. package/scripts/generate_cursor_hooks.py +155 -0
  32. package/scripts/generate_cursor_mdc.py +20 -8
  33. package/scripts/generate_gemini_commands.py +158 -0
  34. package/scripts/generate_gemini_hooks.py +159 -0
  35. package/scripts/generate_gemini_skills.py +98 -0
  36. package/scripts/generate_roo_modes.py +42 -1
  37. package/scripts/generate_windsurf_hooks.py +143 -0
  38. package/scripts/generate_windsurf_rules.py +162 -10
  39. package/scripts/install.py +11 -2
  40. package/scripts/install_steps/ai_tools.py +120 -5
  41. package/scripts/validate.py +20 -3
@@ -1,9 +1,18 @@
1
1
  #!/usr/bin/env python3
2
- """Generate .clinerules/*.md files for Cline.
2
+ """Generate ``.clinerules/*.md`` files and companion workflows for Cline.
3
3
 
4
- Cline reads rules from the .clinerules/ directory (since Cline 3.7).
5
- Each .md file inside .clinerules/ is automatically loaded.
6
- The legacy .clinerules single-file format is replaced by this directory.
4
+ Cline reads rules from the ``.clinerules/`` directory (since Cline 3.7).
5
+ Each ``.md`` file inside is automatically loaded. The legacy single-file
6
+ ``.clinerules`` format is replaced by this directory.
7
+
8
+ This generator also produces:
9
+ * ``.clinerules/workflows/*.md`` — project-local workflow files that
10
+ users invoke with ``/name.md`` in Cline chat. Emitted by default and
11
+ mirrors the same workflow catalogue used by Antigravity and Codex.
12
+ * Conditional rules (YAML ``paths:`` frontmatter) for file-type-scoped
13
+ rules such as testing and language-specific guidance, so Cline only
14
+ loads them when the user is editing matching files. See Cline docs:
15
+ customization/cline-rules#conditional-rules.
7
16
 
8
17
  Usage:
9
18
  python3 scripts/generate_cline_rules.py [target-dir]
@@ -15,28 +24,89 @@ from pathlib import Path
15
24
 
16
25
  sys.path.insert(0, str(Path(__file__).resolve().parent))
17
26
  from dir_rules_shared import (
27
+ LANG_GLOBS,
28
+ LANG_PREFIX,
29
+ PREFIX,
18
30
  STANDARD_RULES,
19
31
  STANDARD_SCOPE,
32
+ STANDARD_WORKFLOWS,
20
33
  build_language_rules,
21
34
  build_registered_rules,
35
+ cleanup_stale,
36
+ rule_testing,
22
37
  write_rules,
23
38
  )
24
39
 
25
40
 
41
+ # ---------------------------------------------------------------------------
42
+ # Conditional-rule helper
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def _conditional(content: str, paths: list[str]) -> str:
46
+ """Prepend a Cline ``paths:`` YAML frontmatter block.
47
+
48
+ Cline activates the rule only when the current working files match
49
+ one of the globs. See docs.cline.bot → customization/cline-rules.
50
+ """
51
+ lines = ["---", "paths:"]
52
+ for p in paths:
53
+ lines.append(f' - "{p}"')
54
+ lines.append("---")
55
+ lines.append("")
56
+ lines.append(content.rstrip("\n"))
57
+ lines.append("")
58
+ return "\n".join(lines)
59
+
60
+
61
+ def _conditional_testing_rule() -> str:
62
+ """Scope the testing rule to test files only (reduces context use)."""
63
+ return _conditional(
64
+ rule_testing(),
65
+ ["**/*.test.*", "**/*.spec.*", "**/test_*", "**/tests/**"],
66
+ )
67
+
68
+
69
+ def _wrap_language_rule(raw: str, lang: str) -> str:
70
+ """Scope a language rule to its file extensions via conditional ``paths``."""
71
+ globs = LANG_GLOBS.get(lang)
72
+ if not globs:
73
+ return raw
74
+ return _conditional(raw, globs)
75
+
76
+
26
77
  def generate(target_dir: Path, *,
27
78
  language_modules: list[str] | None = None,
28
79
  rules_dir: Path | None = None,
29
80
  cleanup: bool = True,
81
+ emit_workflows: bool = True,
30
82
  managed_scopes: tuple[str, ...] = (STANDARD_SCOPE,)) -> None:
31
- """Write .clinerules/*.md files to target_dir."""
32
- # Migrate: if .clinerules exists as a single file, remove it
33
- # so the directory can be created (Cline 3.7+ uses directory format)
83
+ """Write ``.clinerules/*.md`` (and workflow) files to target_dir."""
84
+ # Migrate: if .clinerules exists as a single file, remove it so the
85
+ # directory can be created (Cline 3.7+ uses directory format).
34
86
  clinerules = target_dir / ".clinerules"
35
87
  if clinerules.is_file():
36
88
  clinerules.unlink()
37
- rules = dict(STANDARD_RULES)
38
- rules.update(build_language_rules(language_modules))
89
+
90
+ rules: dict[str, callable] = dict(STANDARD_RULES)
91
+ # Replace the testing rule with a conditional variant so it only
92
+ # loads when the user is editing tests.
93
+ rules[f"{PREFIX}testing.md"] = _conditional_testing_rule
94
+
95
+ # Language rules — wrap each in conditional frontmatter scoped to
96
+ # the language's file globs so the language-specific guidance only
97
+ # loads for matching files.
98
+ for filename, content_fn in build_language_rules(language_modules).items():
99
+ lang = filename.removeprefix(LANG_PREFIX).removesuffix(".md")
100
+ if lang == "common":
101
+ # "common" spans all languages — apply unconditionally.
102
+ rules[filename] = content_fn
103
+ continue
104
+ rules[filename] = (lambda fn, l: lambda: _wrap_language_rule(fn(), l))(
105
+ content_fn, lang,
106
+ )
107
+
39
108
  rules.update(build_registered_rules(rules_dir))
109
+
40
110
  write_rules(
41
111
  target_dir,
42
112
  rules,
@@ -45,6 +115,23 @@ def generate(target_dir: Path, *,
45
115
  managed_scopes=managed_scopes,
46
116
  )
47
117
 
118
+ if emit_workflows:
119
+ _write_workflows(target_dir, cleanup=cleanup)
120
+
121
+
122
+ def _write_workflows(target_dir: Path, *, cleanup: bool = True) -> None:
123
+ """Write ``.clinerules/workflows/*.md`` files (Cline slash-invocable)."""
124
+ workflows_dir = target_dir / ".clinerules" / "workflows"
125
+ workflows_dir.mkdir(parents=True, exist_ok=True)
126
+
127
+ if cleanup:
128
+ # Only touch ai-toolkit-* files; never remove user workflows.
129
+ cleanup_stale(workflows_dir, set(STANDARD_WORKFLOWS.keys()))
130
+
131
+ for filename, content_fn in STANDARD_WORKFLOWS.items():
132
+ (workflows_dir / filename).write_text(content_fn(), encoding="utf-8")
133
+ print(f" Generated: .clinerules/workflows/{filename}")
134
+
48
135
 
49
136
  def main() -> None:
50
137
  target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
@@ -4,8 +4,13 @@
4
4
  Maps compatible ai-toolkit hooks to Codex lifecycle events.
5
5
  Hook scripts are shared with Claude Code (stored in ~/.softspark/ai-toolkit/hooks/).
6
6
 
7
- Codex supports 5 events: SessionStart, PreToolUse, PostToolUse,
8
- UserPromptSubmit, Stop. PreToolUse/PostToolUse only support Bash matcher.
7
+ Codex supports 6 events (PascalCase in config.toml / hooks.json):
8
+ ``PreToolUse``, ``PostToolUse``, ``PermissionRequest``, ``SessionStart``,
9
+ ``UserPromptSubmit``, ``Stop``. PreToolUse/PostToolUse only support
10
+ the ``Bash`` matcher.
11
+
12
+ Handler types in Codex: ``command`` (what we emit), ``prompt``, and ``agent``.
13
+ Reference: codex-rs/config/src/hook_config.rs.
9
14
 
10
15
  Usage:
11
16
  python3 scripts/generate_codex_hooks.py [target-dir]
@@ -33,6 +38,12 @@ CODEX_HOOKS: dict[str, list[tuple[str, str]]] = {
33
38
  ("Bash", "guard-destructive.sh"),
34
39
  ("Bash", "commit-quality.sh"),
35
40
  ],
41
+ "PermissionRequest": [
42
+ # Fires when Codex asks the user to approve a tool call. Our guard
43
+ # reviews the tool input and can veto destructive patterns before the
44
+ # approval prompt reaches the human.
45
+ ("", "guard-destructive.sh"),
46
+ ],
36
47
  "UserPromptSubmit": [
37
48
  ("", "user-prompt-submit.sh"),
38
49
  ("", "track-usage.sh"),
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ """Mirror the full ai-toolkit skill catalogue into ``.codex/skills/``.
3
+
4
+ OpenAI Codex CLI supports the Agent Skills standard at
5
+ ``.codex/skills/<skill-name>/SKILL.md`` with optional supporting files
6
+ (scripts, references, assets) in the skill directory. Unlike the Augment
7
+ and Gemini pointer pattern, Codex benefits from having the full skill
8
+ content on disk, so this generator performs a **full mirror** of every
9
+ skill in ``app/skills/`` into ``<target-dir>/.codex/skills/<name>/``.
10
+
11
+ The mirror is **opt-in**: ``enable_codex_skills=False`` is the default.
12
+ Bucket 4 wires a ``--codex-skills`` CLI flag that toggles this on.
13
+
14
+ Implementation:
15
+ * Prefer symlinks from ``.codex/skills/<name>`` to the canonical
16
+ ``app/skills/<name>`` directory (atomic and cheap).
17
+ * Fall back to a recursive copy when symlinks are unavailable (Windows
18
+ without developer mode, hostile filesystems, etc.).
19
+ * Skip ``_lib`` and any dotfile directories under ``app/skills/``.
20
+ * Remove stale entries under ``.codex/skills/`` that no longer
21
+ correspond to a source skill (cleanup on rerun).
22
+ * Never touch user-authored entries in ``.codex/skills/`` that do not
23
+ match a source skill name and are not our managed targets.
24
+
25
+ Idempotent on rerun.
26
+
27
+ Usage:
28
+ python3 scripts/generate_codex_skills.py [target-dir]
29
+
30
+ By default the CLI entrypoint also requires ``--enable`` to write anything,
31
+ keeping the opt-in default enforced even when invoked directly.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import shutil
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
41
+ from emission import skills_dir
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Source skill discovery
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def _iter_source_skills() -> list[Path]:
49
+ """Return sorted list of source skill directories in ``app/skills/``.
50
+
51
+ Skills starting with ``_`` (library internals) or ``.`` are skipped.
52
+ Only directories containing a top-level ``SKILL.md`` are returned.
53
+ """
54
+ if not skills_dir.is_dir():
55
+ return []
56
+ entries: list[Path] = []
57
+ for entry in sorted(skills_dir.iterdir()):
58
+ if not entry.is_dir():
59
+ continue
60
+ if entry.name.startswith("_") or entry.name.startswith("."):
61
+ continue
62
+ if not (entry / "SKILL.md").is_file():
63
+ continue
64
+ entries.append(entry)
65
+ return entries
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Mirror operations
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def _remove_existing(target: Path) -> None:
73
+ """Remove an existing file, symlink, or directory at ``target``.
74
+
75
+ Uses ``lstat`` so symlinks are unlinked without following them.
76
+ """
77
+ if not target.exists() and not target.is_symlink():
78
+ return
79
+ if target.is_symlink() or target.is_file():
80
+ target.unlink()
81
+ return
82
+ shutil.rmtree(target)
83
+
84
+
85
+ def _symlink_or_copy(source: Path, target: Path) -> str:
86
+ """Create ``target`` as a symlink to ``source``; fall back to a copy.
87
+
88
+ Returns ``"symlink"`` or ``"copy"`` indicating which strategy was used.
89
+ """
90
+ _remove_existing(target)
91
+ target.parent.mkdir(parents=True, exist_ok=True)
92
+ try:
93
+ os.symlink(source, target, target_is_directory=True)
94
+ return "symlink"
95
+ except (OSError, NotImplementedError):
96
+ shutil.copytree(source, target, symlinks=False)
97
+ return "copy"
98
+
99
+
100
+ def _cleanup_stale(codex_skills_dir: Path, live_names: set[str]) -> list[str]:
101
+ """Remove managed entries under ``.codex/skills/`` that no longer map
102
+ to a source skill. Returns the names removed.
103
+
104
+ A managed entry is one whose directory name matches a historical
105
+ source skill name pattern: it contains a ``SKILL.md`` either directly
106
+ (copy) or via a symlink back into ``app/skills/``. User-authored
107
+ entries that do not look managed are left alone.
108
+ """
109
+ if not codex_skills_dir.is_dir():
110
+ return []
111
+ removed: list[str] = []
112
+ for entry in sorted(codex_skills_dir.iterdir()):
113
+ if not entry.is_dir() and not entry.is_symlink():
114
+ continue
115
+ if entry.name in live_names:
116
+ continue
117
+ if entry.is_symlink():
118
+ # Only remove symlinks that point inside our app/skills/ tree.
119
+ try:
120
+ resolved = entry.resolve()
121
+ except OSError:
122
+ continue
123
+ try:
124
+ resolved.relative_to(skills_dir.resolve())
125
+ except ValueError:
126
+ continue
127
+ entry.unlink()
128
+ removed.append(entry.name)
129
+ continue
130
+ # Copy mode: treat as managed only if a SKILL.md is present.
131
+ if (entry / "SKILL.md").is_file():
132
+ shutil.rmtree(entry)
133
+ removed.append(entry.name)
134
+ return removed
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Main
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def generate(target_dir: Path, enable_codex_skills: bool = False) -> None:
142
+ """Mirror ``app/skills/`` into ``<target_dir>/.codex/skills/``.
143
+
144
+ Args:
145
+ target_dir: Project root where ``.codex/skills/`` is written.
146
+ enable_codex_skills: Opt-in flag. Defaults to ``False`` (no-op).
147
+
148
+ Contract for Bucket 4 wiring::
149
+
150
+ from scripts.generate_codex_skills import generate as gen_codex_skills
151
+ gen_codex_skills(target_dir, enable_codex_skills=cli_flag)
152
+
153
+ The ``--codex-skills`` CLI flag should propagate straight into
154
+ ``enable_codex_skills``.
155
+ """
156
+ if not enable_codex_skills:
157
+ return
158
+
159
+ codex_skills_dir = target_dir / ".codex" / "skills"
160
+ codex_skills_dir.mkdir(parents=True, exist_ok=True)
161
+
162
+ sources = _iter_source_skills()
163
+ live_names: set[str] = {s.name for s in sources}
164
+
165
+ symlink_count = 0
166
+ copy_count = 0
167
+ for skill in sources:
168
+ target = codex_skills_dir / skill.name
169
+ mode = _symlink_or_copy(skill, target)
170
+ if mode == "symlink":
171
+ symlink_count += 1
172
+ else:
173
+ copy_count += 1
174
+
175
+ removed = _cleanup_stale(codex_skills_dir, live_names)
176
+
177
+ total = symlink_count + copy_count
178
+ print(
179
+ f" Codex skill mirror: {total} skills "
180
+ f"({symlink_count} symlink, {copy_count} copy)"
181
+ )
182
+ if removed:
183
+ print(f" Codex skill mirror: removed {len(removed)} stale entries")
184
+
185
+
186
+ def main() -> None:
187
+ args = sys.argv[1:]
188
+ enable = "--enable" in args
189
+ positional = [a for a in args if not a.startswith("--")]
190
+ target = Path(positional[0]) if positional else Path.cwd()
191
+ generate(target, enable_codex_skills=enable)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()