@softspark/ai-toolkit 3.0.2 → 3.1.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/AGENTS.md +13 -0
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +30 -23
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/skills/cpp-rules/SKILL.md +275 -0
  6. package/app/skills/csharp-rules/SKILL.md +282 -0
  7. package/app/skills/dart-rules/SKILL.md +299 -0
  8. package/app/skills/golang-rules/SKILL.md +262 -0
  9. package/app/skills/java-rules/SKILL.md +273 -0
  10. package/app/skills/kotlin-rules/SKILL.md +271 -0
  11. package/app/skills/medplum-rules/SKILL.md +271 -0
  12. package/app/skills/php-rules/SKILL.md +292 -0
  13. package/app/skills/python-rules/SKILL.md +257 -0
  14. package/app/skills/ruby-rules/SKILL.md +286 -0
  15. package/app/skills/rust-rules/SKILL.md +276 -0
  16. package/app/skills/swift-rules/SKILL.md +293 -0
  17. package/app/skills/typescript-rules/SKILL.md +249 -0
  18. package/benchmarks/ecosystem-doctor-snapshot.json +14 -14
  19. package/kb/history/completed/deep-coverage-v3-20260423.md +3 -3
  20. package/kb/history/completed/ecosystem-deep-sweep-20260423.md +1 -1
  21. package/kb/procedures/release-preparation-sop.md +4 -4
  22. package/kb/procedures/release-verification-sop.md +11 -12
  23. package/kb/reference/architecture-overview.md +1 -1
  24. package/kb/reference/global-install-model.md +29 -6
  25. package/kb/reference/language-rules.md +54 -18
  26. package/kb/reference/mcp-editor-compatibility.md +4 -3
  27. package/kb/reference/mcp-templates.md +3 -2
  28. package/kb/reference/supported-tools-registry.md +10 -8
  29. package/llms-full.txt +133 -58
  30. package/manifest.json +3 -3
  31. package/package.json +10 -3
  32. package/scripts/codex_skill_adapter.py +19 -3
  33. package/scripts/ecosystem_tools.json +7 -7
  34. package/scripts/generate_cline_rules.py +17 -8
  35. package/scripts/generate_codex_skills.py +33 -96
  36. package/scripts/generate_language_rules_skills.py +232 -0
  37. package/scripts/generate_roo_rules.py +11 -3
  38. package/scripts/install.py +6 -1
  39. package/scripts/install_steps/ai_tools.py +154 -51
  40. package/scripts/install_steps/install_state.py +14 -2
  41. package/scripts/mcp_editors.py +7 -0
@@ -155,15 +155,31 @@ def sync_codex_skill(skill_dir: Path, skills_dst: Path) -> str:
155
155
 
156
156
  def cleanup_codex_skills(skills_dst: Path, skills_src: Path) -> None:
157
157
  """Remove broken symlinks and stale generated Codex skill wrappers."""
158
+ skills_src_resolved = skills_src.resolve()
158
159
  for item in skills_dst.iterdir():
159
160
  src = skills_src / item.name
160
- if item.is_symlink() and not item.exists():
161
- item.unlink()
162
- continue
161
+ if item.is_symlink():
162
+ if not item.exists():
163
+ item.unlink()
164
+ continue
165
+ target = item.resolve()
166
+ if src.is_dir() and target == src.resolve():
167
+ continue
168
+ if _is_relative_to(target, skills_src_resolved):
169
+ item.unlink()
170
+ continue
163
171
  if item.is_dir() and (item / ADAPTED_MARKER).is_file() and not src.is_dir():
164
172
  shutil.rmtree(item)
165
173
 
166
174
 
175
+ def _is_relative_to(path: Path, parent: Path) -> bool:
176
+ try:
177
+ path.relative_to(parent)
178
+ return True
179
+ except ValueError:
180
+ return False
181
+
182
+
167
183
  def _parse_frontmatter(frontmatter_text: str) -> list[tuple[str, str]]:
168
184
  entries: list[tuple[str, str]] = []
169
185
  for line in frontmatter_text.splitlines():
@@ -81,8 +81,7 @@
81
81
  "~/.cursor/mcp.json",
82
82
  ".cursor/skills/*/SKILL.md",
83
83
  ".cursor/agents/*.md",
84
- ".cursor/hooks.json",
85
- "~/.cursor/hooks.json"
84
+ ".cursor/hooks.json"
86
85
  ],
87
86
  "our_generators": [
88
87
  "scripts/generate_cursor_rules.py",
@@ -264,8 +263,8 @@
264
263
  ".roo/rules-{slug}/*.md",
265
264
  ".roo/mcp.json",
266
265
  "~/.roo/rules/",
267
- "~/.roo/settings/custom_modes.yaml",
268
- "~/.roo/settings/mcp_settings.json"
266
+ "~/.roo/custom_modes.yaml",
267
+ "mcp_settings.json"
269
268
  ],
270
269
  "our_generators": [
271
270
  "scripts/generate_roo_modes.py",
@@ -413,14 +412,15 @@
413
412
  "config_paths": [
414
413
  "AGENTS.md",
415
414
  ".agents/rules/*.md",
415
+ ".agents/skills/*/SKILL.md",
416
416
  ".codex/hooks.json",
417
- ".codex/skills/*/SKILL.md",
418
417
  "~/.codex/config.toml"
419
418
  ],
420
419
  "our_generators": [
421
420
  "scripts/generate_codex.py",
422
421
  "scripts/generate_codex_rules.py",
423
- "scripts/generate_codex_hooks.py"
422
+ "scripts/generate_codex_hooks.py",
423
+ "scripts/generate_codex_skills.py"
424
424
  ],
425
425
  "capability_markers": [
426
426
  "AGENTS.md",
@@ -437,7 +437,7 @@
437
437
  "hook handler: command",
438
438
  "hook handler: prompt",
439
439
  "hook handler: agent",
440
- ".codex/skills"
440
+ ".agents/skills"
441
441
  ],
442
442
  "version_probe": {
443
443
  "kind": "command",
@@ -79,13 +79,20 @@ def generate(target_dir: Path, *,
79
79
  rules_dir: Path | None = None,
80
80
  cleanup: bool = True,
81
81
  emit_workflows: bool = True,
82
- managed_scopes: tuple[str, ...] = (STANDARD_SCOPE,)) -> None:
83
- """Write ``.clinerules/*.md`` (and workflow) files to target_dir."""
82
+ managed_scopes: tuple[str, ...] = (STANDARD_SCOPE,),
83
+ output_root: Path | None = None) -> None:
84
+ """Write Cline rule files.
85
+
86
+ By default writes project-local ``target_dir/.clinerules/*.md``. When
87
+ ``output_root`` is provided, writes directly into that directory; this is
88
+ used for Cline's documented global rules directory.
89
+ """
84
90
  # Migrate: if .clinerules exists as a single file, remove it so the
85
91
  # directory can be created (Cline 3.7+ uses directory format).
86
- clinerules = target_dir / ".clinerules"
87
- if clinerules.is_file():
88
- clinerules.unlink()
92
+ if output_root is None:
93
+ clinerules = target_dir / ".clinerules"
94
+ if clinerules.is_file():
95
+ clinerules.unlink()
89
96
 
90
97
  rules: dict[str, callable] = dict(STANDARD_RULES)
91
98
  # Replace the testing rule with a conditional variant so it only
@@ -107,15 +114,17 @@ def generate(target_dir: Path, *,
107
114
 
108
115
  rules.update(build_registered_rules(rules_dir))
109
116
 
117
+ root = output_root.parent if output_root is not None else target_dir
118
+ subdir = output_root.name if output_root is not None else ".clinerules"
110
119
  write_rules(
111
- target_dir,
120
+ root,
112
121
  rules,
113
- ".clinerules",
122
+ subdir,
114
123
  cleanup=cleanup,
115
124
  managed_scopes=managed_scopes,
116
125
  )
117
126
 
118
- if emit_workflows:
127
+ if emit_workflows and output_root is None:
119
128
  _write_workflows(target_dir, cleanup=cleanup)
120
129
 
121
130
 
@@ -1,26 +1,23 @@
1
1
  #!/usr/bin/env python3
2
- """Mirror the full ai-toolkit skill catalogue into ``.codex/skills/``.
2
+ """Mirror the ai-toolkit skill catalogue into Codex ``.agents/skills/``.
3
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>/``.
4
+ OpenAI Codex CLI discovers Agent Skills from ``.agents/skills/`` in the
5
+ repository tree, plus user/admin/system skill locations. Unlike the Augment
6
+ and Gemini pointer pattern, Codex benefits from having the full skill catalog
7
+ on disk, so this generator syncs every skill in ``app/skills/`` into
8
+ ``<target-dir>/.agents/skills/<name>/``.
10
9
 
11
10
  The mirror is **opt-in**: ``enable_codex_skills=False`` is the default.
12
11
  Bucket 4 wires a ``--codex-skills`` CLI flag that toggles this on.
13
12
 
14
13
  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.).
14
+ * Native Codex-compatible skills are symlinked to canonical ``app/skills``.
15
+ * Skills that use Claude-only delegation tools are rendered as Codex
16
+ wrappers through ``codex_skill_adapter.sync_codex_skill``.
19
17
  * 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.
18
+ * Remove stale generated wrappers and broken managed symlinks.
19
+ * Preserve user-authored entries in ``.agents/skills/`` that are not managed
20
+ by ai-toolkit.
24
21
 
25
22
  Idempotent on rerun.
26
23
 
@@ -32,12 +29,11 @@ keeping the opt-in default enforced even when invoked directly.
32
29
  """
33
30
  from __future__ import annotations
34
31
 
35
- import os
36
- import shutil
37
32
  import sys
38
33
  from pathlib import Path
39
34
 
40
35
  sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+ from codex_skill_adapter import cleanup_codex_skills, sync_codex_skill
41
37
  from emission import skills_dir
42
38
 
43
39
 
@@ -69,69 +65,11 @@ def _iter_source_skills() -> list[Path]:
69
65
  # Mirror operations
70
66
  # ---------------------------------------------------------------------------
71
67
 
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
- """
68
+ def _count_entries(codex_skills_dir: Path) -> int:
69
+ """Count top-level skill entries after sync."""
109
70
  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
71
+ return 0
72
+ return sum(1 for entry in codex_skills_dir.iterdir() if entry.is_dir() or entry.is_symlink())
135
73
 
136
74
 
137
75
  # ---------------------------------------------------------------------------
@@ -139,10 +77,10 @@ def _cleanup_stale(codex_skills_dir: Path, live_names: set[str]) -> list[str]:
139
77
  # ---------------------------------------------------------------------------
140
78
 
141
79
  def generate(target_dir: Path, enable_codex_skills: bool = False) -> None:
142
- """Mirror ``app/skills/`` into ``<target_dir>/.codex/skills/``.
80
+ """Mirror ``app/skills/`` into ``<target_dir>/.agents/skills/``.
143
81
 
144
82
  Args:
145
- target_dir: Project root where ``.codex/skills/`` is written.
83
+ target_dir: Project root where ``.agents/skills/`` is written.
146
84
  enable_codex_skills: Opt-in flag. Defaults to ``False`` (no-op).
147
85
 
148
86
  Contract for Bucket 4 wiring::
@@ -156,31 +94,30 @@ def generate(target_dir: Path, enable_codex_skills: bool = False) -> None:
156
94
  if not enable_codex_skills:
157
95
  return
158
96
 
159
- codex_skills_dir = target_dir / ".codex" / "skills"
97
+ codex_skills_dir = target_dir / ".agents" / "skills"
160
98
  codex_skills_dir.mkdir(parents=True, exist_ok=True)
161
99
 
162
100
  sources = _iter_source_skills()
163
- live_names: set[str] = {s.name for s in sources}
164
101
 
165
- symlink_count = 0
166
- copy_count = 0
102
+ linked = 0
103
+ adapted = 0
104
+ skipped = 0
167
105
  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
106
+ mode = sync_codex_skill(skill, codex_skills_dir)
107
+ if mode == "linked":
108
+ linked += 1
109
+ elif mode == "adapted":
110
+ adapted += 1
172
111
  else:
173
- copy_count += 1
112
+ skipped += 1
174
113
 
175
- removed = _cleanup_stale(codex_skills_dir, live_names)
114
+ cleanup_codex_skills(codex_skills_dir, skills_dir)
176
115
 
177
- total = symlink_count + copy_count
178
116
  print(
179
- f" Codex skill mirror: {total} skills "
180
- f"({symlink_count} symlink, {copy_count} copy)"
117
+ f" Codex skill mirror: {_count_entries(codex_skills_dir)} skills "
118
+ f"to .agents/skills/ ({linked} linked, {adapted} adapted, "
119
+ f"{skipped} skipped)"
181
120
  )
182
- if removed:
183
- print(f" Codex skill mirror: removed {len(removed)} stale entries")
184
121
 
185
122
 
186
123
  def main() -> None:
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """Generate language-rules knowledge skills from app/rules/<lang>/*.md.
3
+
4
+ Each language directory under ``app/rules/`` (except ``common/``) is compiled
5
+ into a single ``app/skills/<lang>-rules/SKILL.md`` knowledge skill. The skill
6
+ is ``user-invocable: false`` so Claude loads it contextually when the
7
+ description triggers match (file extensions, framework names).
8
+
9
+ This is the proper progressive-disclosure replacement for the v1.3.8 pointer
10
+ block in ``.claude/CLAUDE.md``: instead of nudging Claude to Read absolute
11
+ nvm-pinned paths on demand, the rules ride on the Agent Skills mechanism.
12
+
13
+ Common rules (``app/rules/common/``) stay inlined in ``CLAUDE.md`` because
14
+ they are language-agnostic and should be visible regardless of context.
15
+
16
+ Idempotent: rerunning overwrites generated SKILL.md but leaves any other
17
+ files in the skill directory alone.
18
+
19
+ Usage:
20
+ python3 scripts/generate_language_rules_skills.py # write all
21
+ python3 scripts/generate_language_rules_skills.py --check # dry-run
22
+ python3 scripts/generate_language_rules_skills.py --langs python,rust
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import sys
28
+ from pathlib import Path
29
+ from textwrap import dedent
30
+
31
+ ROOT = Path(__file__).resolve().parent.parent
32
+ RULES_DIR = ROOT / "app" / "rules"
33
+ SKILLS_DIR = ROOT / "app" / "skills"
34
+
35
+ # Per-language description triggers. Concrete file extensions and framework
36
+ # names give Claude a high-signal match against user prompts and file paths,
37
+ # so the skill activates reliably when the user is actually working in that
38
+ # language.
39
+ TRIGGERS: dict[str, dict[str, str]] = {
40
+ "python": {
41
+ "label": "Python",
42
+ "triggers": ".py, .pyi, pyproject.toml, requirements.txt, Pipfile, FastAPI, Django, Flask, pytest, SQLAlchemy, ruff, mypy",
43
+ },
44
+ "typescript": {
45
+ "label": "TypeScript/JavaScript",
46
+ "triggers": ".ts, .tsx, .js, .jsx, package.json, tsconfig.json, React, Next.js, Vue, Vite, Vitest, Jest, ESLint",
47
+ },
48
+ "golang": {
49
+ "label": "Go",
50
+ "triggers": ".go, go.mod, go.sum, Gin, Echo, Gorilla, testing, gofmt",
51
+ },
52
+ "rust": {
53
+ "label": "Rust",
54
+ "triggers": ".rs, Cargo.toml, Cargo.lock, Tokio, Axum, Serde, clippy, cargo test",
55
+ },
56
+ "java": {
57
+ "label": "Java",
58
+ "triggers": ".java, pom.xml, build.gradle, Spring, Spring Boot, JPA, Hibernate, JUnit, Maven, Gradle",
59
+ },
60
+ "kotlin": {
61
+ "label": "Kotlin",
62
+ "triggers": ".kt, .kts, build.gradle.kts, Ktor, Jetpack Compose, coroutines, kotlinx",
63
+ },
64
+ "swift": {
65
+ "label": "Swift",
66
+ "triggers": ".swift, Package.swift, .xcodeproj, SwiftUI, Combine, async/await, XCTest",
67
+ },
68
+ "dart": {
69
+ "label": "Dart/Flutter",
70
+ "triggers": ".dart, pubspec.yaml, Flutter, Riverpod, Bloc, widget, StatelessWidget, StatefulWidget",
71
+ },
72
+ "csharp": {
73
+ "label": "C#/.NET",
74
+ "triggers": ".cs, .csproj, .sln, ASP.NET, ASP.NET Core, EF Core, LINQ, NUnit, xUnit, dotnet",
75
+ },
76
+ "php": {
77
+ "label": "PHP",
78
+ "triggers": ".php, composer.json, Laravel, Symfony, PHPUnit, PSR-12, Composer",
79
+ },
80
+ "cpp": {
81
+ "label": "C++",
82
+ "triggers": ".cpp, .cc, .cxx, .hpp, .h, CMakeLists.txt, Makefile, GoogleTest, clang-tidy",
83
+ },
84
+ "ruby": {
85
+ "label": "Ruby",
86
+ "triggers": ".rb, Gemfile, .gemspec, Rails, ActiveRecord, Sidekiq, RSpec, Sorbet, rubocop",
87
+ },
88
+ "medplum": {
89
+ "label": "Medplum (FHIR healthcare)",
90
+ "triggers": "medplum.config.mts, medplum.config.ts, FHIR, Medplum, Bot, Subscription, Questionnaire",
91
+ },
92
+ }
93
+
94
+
95
+ def _strip_frontmatter(text: str) -> str:
96
+ """Remove YAML frontmatter (--- ... ---) if present."""
97
+ if not text.startswith("---"):
98
+ return text.lstrip("\n")
99
+ end = text.find("\n---", 3)
100
+ if end == -1:
101
+ return text.lstrip("\n")
102
+ return text[end + 4:].lstrip("\n")
103
+
104
+
105
+ def _category_title(stem: str) -> str:
106
+ """Convert filename stem (e.g. ``coding-style``) to a section title."""
107
+ return " ".join(part.capitalize() for part in stem.split("-"))
108
+
109
+
110
+ def _build_skill_body(lang_dir: Path) -> str:
111
+ """Concatenate all rule category files into a skill body."""
112
+ parts: list[str] = []
113
+ for f in sorted(lang_dir.glob("*.md")):
114
+ body = _strip_frontmatter(f.read_text(encoding="utf-8")).rstrip()
115
+ # If the source file already starts with a top-level "# Title", keep
116
+ # it. Otherwise, prepend a "## Category" header so the skill body
117
+ # has structure.
118
+ if body.lstrip().startswith("#"):
119
+ parts.append(body)
120
+ else:
121
+ parts.append(f"## {_category_title(f.stem)}\n\n{body}")
122
+ return "\n\n".join(parts) + "\n"
123
+
124
+
125
+ def _build_description(lang: str) -> str:
126
+ meta = TRIGGERS.get(lang)
127
+ if not meta:
128
+ return (
129
+ f"{lang.capitalize()} coding rules: coding-style, frameworks, "
130
+ f"patterns, security, testing. Load when writing or reviewing "
131
+ f"{lang.capitalize()} code."
132
+ )
133
+ label = meta["label"]
134
+ triggers = meta["triggers"]
135
+ return (
136
+ f"{label} coding rules from ai-toolkit: coding-style, frameworks, "
137
+ f"patterns, security, testing. "
138
+ f"Triggers: {triggers}. "
139
+ f"Load when writing, reviewing, or editing {label} code."
140
+ )
141
+
142
+
143
+ def _build_skill_md(lang: str, lang_dir: Path) -> str:
144
+ description = _build_description(lang)
145
+ body = _build_skill_body(lang_dir)
146
+ label = TRIGGERS.get(lang, {}).get("label", lang.capitalize())
147
+ frontmatter = dedent(
148
+ f"""\
149
+ ---
150
+ name: {lang}-rules
151
+ description: "{description}"
152
+ effort: medium
153
+ user-invocable: false
154
+ allowed-tools: Read
155
+ ---
156
+
157
+ # {label} Rules
158
+
159
+ These rules come from `app/rules/{lang}/` in ai-toolkit. They cover
160
+ the project's standards for coding style, frameworks, patterns,
161
+ security, and testing in {label}. Apply them when writing or
162
+ reviewing {label} code.
163
+
164
+ """
165
+ )
166
+ return frontmatter + body
167
+
168
+
169
+ def discover_languages() -> list[str]:
170
+ """List language directories under app/rules/ excluding ``common``."""
171
+ if not RULES_DIR.is_dir():
172
+ return []
173
+ out: list[str] = []
174
+ for d in sorted(RULES_DIR.iterdir()):
175
+ if not d.is_dir() or d.name == "common":
176
+ continue
177
+ if any(d.glob("*.md")):
178
+ out.append(d.name)
179
+ return out
180
+
181
+
182
+ def generate(langs: list[str] | None = None, check: bool = False) -> int:
183
+ """Generate skills. Returns count of skills written (or that would be)."""
184
+ languages = langs if langs else discover_languages()
185
+ written = 0
186
+ for lang in languages:
187
+ lang_dir = RULES_DIR / lang
188
+ if not lang_dir.is_dir():
189
+ print(f" SKIP: {lang} (directory missing)", file=sys.stderr)
190
+ continue
191
+ skill_dir = SKILLS_DIR / f"{lang}-rules"
192
+ skill_md = skill_dir / "SKILL.md"
193
+ content = _build_skill_md(lang, lang_dir)
194
+
195
+ if check:
196
+ existing = skill_md.read_text(encoding="utf-8") if skill_md.is_file() else ""
197
+ status = "OK" if existing == content else "DIFF"
198
+ print(f" [{status}] {skill_md.relative_to(ROOT)}")
199
+ if existing != content:
200
+ written += 1
201
+ continue
202
+
203
+ skill_dir.mkdir(parents=True, exist_ok=True)
204
+ skill_md.write_text(content, encoding="utf-8")
205
+ print(f" Wrote: {skill_md.relative_to(ROOT)}")
206
+ written += 1
207
+ return written
208
+
209
+
210
+ def main() -> int:
211
+ ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
212
+ ap.add_argument(
213
+ "--langs",
214
+ default="",
215
+ help="Comma-separated language list (default: all)",
216
+ )
217
+ ap.add_argument(
218
+ "--check",
219
+ action="store_true",
220
+ help="Dry-run: report which skills would change without writing",
221
+ )
222
+ args = ap.parse_args()
223
+ langs = [s.strip() for s in args.langs.split(",") if s.strip()] or None
224
+ n = generate(langs=langs, check=args.check)
225
+ if args.check and n > 0:
226
+ print(f"\n{n} skill(s) out of date. Re-run without --check.", file=sys.stderr)
227
+ return 1
228
+ return 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ sys.exit(main())
@@ -23,12 +23,20 @@ from dir_rules_shared import (
23
23
 
24
24
  def generate(target_dir: Path, *,
25
25
  language_modules: list[str] | None = None,
26
- rules_dir: Path | None = None) -> None:
27
- """Write .roo/rules/*.md files to target_dir."""
26
+ rules_dir: Path | None = None,
27
+ output_root: Path | None = None) -> None:
28
+ """Write Roo Code rule files.
29
+
30
+ By default writes project-local ``target_dir/.roo/rules/*.md``. When
31
+ ``output_root`` is provided, writes directly into that directory for
32
+ documented global rules such as ``~/.roo/rules``.
33
+ """
28
34
  rules = dict(STANDARD_RULES)
29
35
  rules.update(build_language_rules(language_modules))
30
36
  rules.update(build_registered_rules(rules_dir))
31
- write_rules(target_dir, rules, ".roo/rules")
37
+ root = output_root.parent if output_root is not None else target_dir
38
+ subdir = output_root.name if output_root is not None else ".roo/rules"
39
+ write_rules(root, rules, subdir)
32
40
 
33
41
 
34
42
  def main() -> None:
@@ -12,9 +12,14 @@ Claude Code (~/.claude/):
12
12
  - Rules injected into ~/.claude/CLAUDE.md
13
13
 
14
14
  Other tools (global config locations):
15
- - Cursor: ~/.cursor/rules
16
15
  - Windsurf: ~/.codeium/windsurf/memories/global_rules.md
17
16
  - Gemini: ~/.gemini/GEMINI.md
17
+ - Cline: ~/Documents/Cline/Rules/
18
+ - Roo Code: ~/.roo/rules/
19
+ - Aider: ~/.aider.conf.yml (created only if absent)
20
+ - Augment: ~/.augment/rules/ai-toolkit.md
21
+ - Codex: ~/AGENTS.md, ~/.agents/, ~/.codex/hooks.json
22
+ - opencode: ~/.config/opencode/
18
23
 
19
24
  Registered rules (~/.softspark/ai-toolkit/rules/*.md) are also injected into
20
25
  all of the above. Add rules with: ai-toolkit add-rule <rule.md>