@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,391 @@
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 Cline CLI and extension-compatible hook executables."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+
15
+ from secure_fs import (
16
+ SecureDestination,
17
+ SecureTransaction,
18
+ lexical_absolute,
19
+ nearest_existing_root,
20
+ run_secure_transaction,
21
+ )
22
+
23
+
24
+ HOOK_EVENTS = (
25
+ "TaskStart",
26
+ "TaskResume",
27
+ "TaskCancel",
28
+ "TaskComplete",
29
+ "PreToolUse",
30
+ "PostToolUse",
31
+ "UserPromptSubmit",
32
+ "PreCompact",
33
+ )
34
+ MANAGED_MARKER = "# ai-toolkit-managed: cline-hook"
35
+
36
+
37
+ HOOK_SOURCE = '''#!/usr/bin/env python3
38
+ # SPDX-License-Identifier: Apache-2.0
39
+ # ai-toolkit-managed: cline-hook
40
+ """Self-contained ai-toolkit adapter for one native Cline hook event."""
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import re
46
+ import signal
47
+ import sys
48
+ from pathlib import Path
49
+ from typing import Any
50
+
51
+
52
+ MAX_INPUT_BYTES = 1024 * 1024
53
+ MAX_RUNTIME_SECONDS = 4
54
+ EVENTS = {
55
+ "TaskStart",
56
+ "TaskResume",
57
+ "TaskCancel",
58
+ "TaskComplete",
59
+ "PreToolUse",
60
+ "PostToolUse",
61
+ "UserPromptSubmit",
62
+ "PreCompact",
63
+ }
64
+ COMMAND_TOOLS = {
65
+ "execute_command",
66
+ "run_command",
67
+ "run_commands",
68
+ "bash",
69
+ "shell",
70
+ }
71
+ DESTRUCTIVE_PATTERNS = tuple(
72
+ re.compile(pattern, re.IGNORECASE)
73
+ for pattern in (
74
+ r"(?:^|[;&|]\\s*)rm\\s+(?:-[rRf]+|--recursive|--force)\\b",
75
+ r"\\bsudo\\s+rm\\b",
76
+ r"\\bgit\\s+reset\\s+--hard\\b",
77
+ r"\\bgit\\s+clean\\s+-[^\\s]*f",
78
+ r"\\bgit\\s+push\\s+.*(?:--force(?:\\s|$)|-f(?:\\s|$))",
79
+ r"\\b(?:mkfs|shred)\\b",
80
+ r"\\bdd\\s+if=.+\\s+of=/dev/",
81
+ r"\\bDROP\\s+(?:DATABASE|SCHEMA|TABLE)\\b",
82
+ r"\\bTRUNCATE\\s+(?:TABLE\\s+)?\\S+",
83
+ r"\\bterraform\\s+destroy\\b",
84
+ r"\\bkubectl\\s+delete\\s+(?:namespace|ns|all|node)\\b",
85
+ )
86
+ )
87
+ CONTEXT_BY_EVENT = {
88
+ "TaskStart": (
89
+ "Follow repository instructions, keep changes scoped, and verify before "
90
+ "claiming completion."
91
+ ),
92
+ "TaskResume": (
93
+ "Reconfirm the current objective, pending work, and repository state "
94
+ "before continuing."
95
+ ),
96
+ "UserPromptSubmit": (
97
+ "For technical work, search the project knowledge base first and cite "
98
+ "the source paths used."
99
+ ),
100
+ "PreCompact": (
101
+ "Preserve the objective, completed and pending work, modified files, "
102
+ "decisions, and verification evidence during compaction."
103
+ ),
104
+ }
105
+
106
+
107
+ def _timeout(_signum: int, _frame: object) -> None:
108
+ raise TimeoutError("Cline hook exceeded its deadline")
109
+
110
+
111
+ def _read_payload() -> dict[str, Any]:
112
+ raw = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1)
113
+ if len(raw) > MAX_INPUT_BYTES:
114
+ raise ValueError("Cline hook payload exceeds 1 MiB")
115
+ if not raw.strip():
116
+ return {}
117
+ value = json.loads(raw)
118
+ if not isinstance(value, dict):
119
+ raise ValueError("Cline hook payload must be an object")
120
+ return value
121
+
122
+
123
+ def _pre_tool_use(payload: dict[str, Any]) -> dict[str, Any]:
124
+ data = payload.get("preToolUse")
125
+ if not isinstance(data, dict):
126
+ return {"cancel": True, "errorMessage": "Invalid preToolUse payload."}
127
+ tool = data.get("toolName")
128
+ parameters = data.get("parameters")
129
+ if not isinstance(tool, str) or not tool:
130
+ return {"cancel": True, "errorMessage": "Invalid toolName value."}
131
+ if not isinstance(parameters, dict):
132
+ return {"cancel": True, "errorMessage": "Invalid command parameters."}
133
+ if tool.lower() not in COMMAND_TOOLS:
134
+ return {"cancel": False}
135
+ commands = parameters.get("commands") if tool.lower() == "run_commands" else None
136
+ if commands is None:
137
+ commands = [parameters.get("command")]
138
+ if (
139
+ not isinstance(commands, list)
140
+ or not commands
141
+ or any(
142
+ not isinstance(command, str) or not command.strip()
143
+ for command in commands
144
+ )
145
+ ):
146
+ return {"cancel": True, "errorMessage": "Invalid command value."}
147
+ if any(
148
+ pattern.search(command)
149
+ for command in commands
150
+ for pattern in DESTRUCTIVE_PATTERNS
151
+ ):
152
+ return {
153
+ "cancel": True,
154
+ "errorMessage": "Potentially destructive command requires explicit user approval.",
155
+ }
156
+ return {"cancel": False}
157
+
158
+
159
+ def respond(event: str, payload: dict[str, Any]) -> dict[str, Any]:
160
+ if event == "PreToolUse":
161
+ return _pre_tool_use(payload)
162
+ if event in CONTEXT_BY_EVENT:
163
+ return {
164
+ "cancel": False,
165
+ "contextModification": CONTEXT_BY_EVENT[event],
166
+ "errorMessage": "",
167
+ }
168
+ return {"cancel": False}
169
+
170
+
171
+ def main() -> int:
172
+ event = Path(sys.argv[0]).name
173
+ if event not in EVENTS:
174
+ print("Unsupported Cline hook event", file=sys.stderr)
175
+ return 2
176
+ signal.signal(signal.SIGALRM, _timeout)
177
+ signal.alarm(MAX_RUNTIME_SECONDS)
178
+ try:
179
+ result = respond(event, _read_payload())
180
+ print(json.dumps(result, separators=(",", ":"), sort_keys=True))
181
+ return 0
182
+ except (json.JSONDecodeError, TimeoutError, ValueError) as error:
183
+ print(
184
+ json.dumps(
185
+ {
186
+ "cancel": True,
187
+ "errorMessage": f"Cline hook input rejected: {error}",
188
+ },
189
+ separators=(",", ":"),
190
+ sort_keys=True,
191
+ )
192
+ )
193
+ return 0
194
+ finally:
195
+ signal.alarm(0)
196
+
197
+
198
+ if __name__ == "__main__":
199
+ raise SystemExit(main())
200
+ '''
201
+
202
+
203
+ def _hook_paths(target_dir: Path, hooks_root: Path | None = None) -> list[Path]:
204
+ hooks_dir = hooks_root or target_dir / ".cline" / "hooks"
205
+ return [hooks_dir / event for event in HOOK_EVENTS]
206
+
207
+
208
+ def _default_hook_roots(target: Path) -> list[Path]:
209
+ roots = [target / ".cline" / "hooks"]
210
+ compatibility_root = target / ".clinerules"
211
+ if compatibility_root.is_symlink():
212
+ raise RuntimeError(
213
+ f"Unsafe symlinked Cline hooks ancestor: {compatibility_root}"
214
+ )
215
+ if not compatibility_root.is_file():
216
+ roots.append(compatibility_root / "hooks")
217
+ return roots
218
+
219
+
220
+ def _stale_candidates(hooks_dir: Path) -> list[Path]:
221
+ if not hooks_dir.exists():
222
+ return []
223
+ if hooks_dir.is_symlink() or not hooks_dir.is_dir():
224
+ raise RuntimeError(f"Unsafe Cline hooks directory: {hooks_dir}")
225
+ active_names = set(HOOK_EVENTS)
226
+ return sorted(
227
+ (
228
+ path
229
+ for path in hooks_dir.iterdir()
230
+ if path.name not in active_names
231
+ and not path.is_symlink()
232
+ and path.is_file()
233
+ ),
234
+ key=lambda path: path.name,
235
+ )
236
+
237
+
238
+ def _reject_symlink_ancestors(target: Path, output_root: Path) -> None:
239
+ try:
240
+ relative = output_root.relative_to(target)
241
+ except ValueError as error:
242
+ raise RuntimeError(
243
+ f"Cline hooks root escapes target directory: {output_root}"
244
+ ) from error
245
+ current = target
246
+ for part in relative.parts:
247
+ current = current / part
248
+ if current.is_symlink():
249
+ raise RuntimeError(f"Unsafe symlinked Cline hooks ancestor: {current}")
250
+ if not current.exists():
251
+ break
252
+ if not current.is_dir():
253
+ raise RuntimeError(f"Unsafe non-directory Cline hooks ancestor: {current}")
254
+
255
+
256
+ def generate(target_dir: Path, *, hooks_root: Path | None = None) -> Path:
257
+ """Write all eight Cline hook executables atomically.
258
+
259
+ ``hooks_root`` selects a documented compatibility root while keeping
260
+ ``target_dir`` as the trusted filesystem boundary. Without an override,
261
+ project hooks are dual-emitted to ``.cline/hooks`` and
262
+ ``.clinerules/hooks`` unless a legacy ``.clinerules`` file occupies the
263
+ compatibility path.
264
+ """
265
+ target = lexical_absolute(target_dir)
266
+ if target.is_symlink() or not target.is_dir():
267
+ raise RuntimeError(f"Unsafe Cline target directory: {target}")
268
+ output_roots = (
269
+ [lexical_absolute(hooks_root)]
270
+ if hooks_root is not None
271
+ else _default_hook_roots(target)
272
+ )
273
+ for output_root in output_roots:
274
+ _reject_symlink_ancestors(target, output_root)
275
+ root = nearest_existing_root(target)
276
+ active_destinations = [
277
+ SecureDestination(path, root, f"Cline {path.name} hook")
278
+ for output_root in output_roots
279
+ for path in _hook_paths(target, output_root)
280
+ ]
281
+ stale_destinations = [
282
+ SecureDestination(path, root, f"Cline stale {path.name} hook")
283
+ for output_root in output_roots
284
+ for path in _stale_candidates(output_root)
285
+ ]
286
+ destinations = active_destinations + stale_destinations
287
+
288
+ def apply(transaction: SecureTransaction) -> None:
289
+ for destination in active_destinations:
290
+ content = transaction.initial_content(destination)
291
+ if content is not None and MANAGED_MARKER.encode() not in content[:256]:
292
+ raise RuntimeError(
293
+ f"Refusing user-owned Cline hook: {destination.path}"
294
+ )
295
+ for destination in active_destinations:
296
+ transaction.atomic_write(destination, HOOK_SOURCE.encode(), 0o755)
297
+ for destination in stale_destinations:
298
+ content = transaction.initial_content(destination)
299
+ if content is not None and MANAGED_MARKER.encode() in content[:256]:
300
+ transaction.unlink(destination)
301
+
302
+ run_secure_transaction(destinations, apply)
303
+ return output_roots[0]
304
+
305
+
306
+ def generate_global(home_dir: Path) -> Path:
307
+ """Write hooks below Cline CLI's global ``~/.cline/hooks`` root."""
308
+ home = lexical_absolute(home_dir)
309
+ return generate(home, hooks_root=home / ".cline" / "hooks")
310
+
311
+
312
+ def generate_extension_global(home_dir: Path) -> Path:
313
+ """Write extension-compatible hooks below ``~/Documents/Cline/Hooks``."""
314
+ home = lexical_absolute(home_dir)
315
+ return generate(home, hooks_root=home / "Documents" / "Cline" / "Hooks")
316
+
317
+
318
+ def cleanup(target_dir: Path, *, hooks_root: Path | None = None) -> None:
319
+ """Remove only ai-toolkit-managed hook files from a Cline target."""
320
+ target = lexical_absolute(target_dir)
321
+ if target.is_symlink() or not target.is_dir():
322
+ return
323
+ hook_roots = (
324
+ [lexical_absolute(hooks_root)]
325
+ if hooks_root is not None
326
+ else _default_hook_roots(target)
327
+ )
328
+ for hooks_dir in hook_roots:
329
+ _reject_symlink_ancestors(target, hooks_dir)
330
+ root = nearest_existing_root(target)
331
+ candidates = [
332
+ path
333
+ for hooks_dir in hook_roots
334
+ if hooks_dir.is_dir()
335
+ for path in _hook_paths(target, hooks_dir) + _stale_candidates(hooks_dir)
336
+ ]
337
+ destinations = [
338
+ SecureDestination(path, root, f"Cline {path.name} hook")
339
+ for path in candidates
340
+ if path.is_file() and not path.is_symlink()
341
+ ]
342
+ if not destinations:
343
+ return
344
+
345
+ def apply(transaction: SecureTransaction) -> None:
346
+ for destination in destinations:
347
+ content = transaction.initial_content(destination)
348
+ if content is not None and MANAGED_MARKER.encode() in content[:256]:
349
+ transaction.unlink(destination)
350
+
351
+ run_secure_transaction(destinations, apply)
352
+
353
+
354
+ def discover(target_dir: Path, *, hooks_root: Path | None = None) -> int:
355
+ """Return the number of ai-toolkit-managed Cline hook files."""
356
+ target = lexical_absolute(target_dir)
357
+ if target.is_symlink() or not target.is_dir():
358
+ raise RuntimeError(f"Unsafe Cline target directory: {target}")
359
+ roots = (
360
+ [lexical_absolute(hooks_root)]
361
+ if hooks_root is not None
362
+ else _default_hook_roots(target)
363
+ )
364
+ for root in roots:
365
+ _reject_symlink_ancestors(target, root)
366
+ count = 0
367
+ for root in roots:
368
+ if not root.is_dir():
369
+ continue
370
+ for path in root.iterdir():
371
+ if path.is_symlink() or not path.is_file():
372
+ continue
373
+ try:
374
+ with path.open("rb") as handle:
375
+ content = handle.read(256)
376
+ except OSError:
377
+ continue
378
+ if MANAGED_MARKER.encode() in content:
379
+ count += 1
380
+ return count
381
+
382
+
383
+ def main() -> None:
384
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
385
+ generate(target)
386
+ roots = _default_hook_roots(lexical_absolute(target))
387
+ print(f"Generated: {', '.join(map(str, roots))} ({len(HOOK_EVENTS)} hooks each)")
388
+
389
+
390
+ if __name__ == "__main__":
391
+ main()
@@ -3,11 +3,11 @@
3
3
  # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
4
4
  # Source: https://github.com/softspark/ai-toolkit
5
5
 
6
- """Generate ``.clinerules/*.md`` files and companion workflows for Cline.
6
+ """Generate native Cline rules plus extension-compatible rule files.
7
7
 
8
- Cline reads rules from the ``.clinerules/`` directory (since Cline 3.7).
9
- Each ``.md`` file inside is automatically loaded. The legacy single-file
10
- ``.clinerules`` format is replaced by this directory.
8
+ Cline CLI/SDK reads ``.cline/rules/*.md``. The IDE extension also reads the
9
+ ``.clinerules/*.md`` compatibility surface. A user-owned legacy single-file
10
+ ``.clinerules`` is preserved byte-identically while native rules are emitted.
11
11
 
12
12
  This generator also produces:
13
13
  * ``.clinerules/workflows/*.md`` — project-local workflow files that
@@ -36,9 +36,15 @@ from dir_rules_shared import (
36
36
  STANDARD_WORKFLOWS,
37
37
  build_language_rules,
38
38
  build_registered_rules,
39
- cleanup_stale,
39
+ rule_scope,
40
40
  rule_testing,
41
- write_rules,
41
+ )
42
+ from secure_fs import (
43
+ SecureDestination,
44
+ SecureTransaction,
45
+ lexical_absolute,
46
+ nearest_existing_root,
47
+ run_secure_transaction,
42
48
  )
43
49
 
44
50
 
@@ -78,25 +84,114 @@ def _wrap_language_rule(raw: str, lang: str) -> str:
78
84
  return _conditional(raw, globs)
79
85
 
80
86
 
81
- def generate(target_dir: Path, *,
82
- language_modules: list[str] | None = None,
83
- rules_dir: Path | None = None,
84
- cleanup: bool = True,
85
- emit_workflows: bool = True,
86
- managed_scopes: tuple[str, ...] = (STANDARD_SCOPE,),
87
- output_root: Path | None = None) -> None:
87
+ def _trusted_target(target_dir: Path) -> tuple[Path, Path]:
88
+ target = lexical_absolute(target_dir)
89
+ if target.is_symlink() or not target.is_dir():
90
+ raise RuntimeError(f"Unsafe Cline target directory: {target}")
91
+ return target, nearest_existing_root(target)
92
+
93
+
94
+ def _output_root(target: Path, root: Path) -> Path:
95
+ output = lexical_absolute(root)
96
+ try:
97
+ output.relative_to(target)
98
+ except ValueError as error:
99
+ raise RuntimeError(f"Cline rules root escapes target: {output}") from error
100
+ return output
101
+
102
+
103
+ def _preflight_roots(target: Path, roots: list[Path], trusted_root: Path) -> None:
104
+ probes = [
105
+ SecureDestination(
106
+ root / ".ai-toolkit-secure-probe",
107
+ trusted_root,
108
+ f"Cline {root.relative_to(target)} ancestry",
109
+ )
110
+ for root in roots
111
+ ]
112
+ transaction = SecureTransaction(probes)
113
+ transaction.close()
114
+
115
+
116
+ def _managed_paths(root: Path, scopes: set[str]) -> list[Path]:
117
+ if not root.exists():
118
+ return []
119
+ if root.is_symlink() or not root.is_dir():
120
+ raise RuntimeError(f"Unsafe Cline rules directory: {root}")
121
+ return sorted(
122
+ path for path in root.iterdir() if rule_scope(path.name) in scopes
123
+ )
124
+
125
+
126
+ def _render_rules(rules: dict[str, callable]) -> dict[str, bytes]:
127
+ return {
128
+ filename: content_fn().encode("utf-8")
129
+ for filename, content_fn in rules.items()
130
+ }
131
+
132
+
133
+ def _write_transaction(
134
+ target: Path,
135
+ trusted_root: Path,
136
+ outputs: dict[Path, dict[str, bytes]],
137
+ *,
138
+ cleanup: bool,
139
+ managed_scopes: tuple[str, ...],
140
+ ) -> None:
141
+ active = [
142
+ SecureDestination(root / name, trusted_root, f"Cline {name}")
143
+ for root, files in outputs.items()
144
+ for name in files
145
+ ]
146
+ stale: list[SecureDestination] = []
147
+ if cleanup:
148
+ scopes = set(managed_scopes)
149
+ for root, files in outputs.items():
150
+ for path in _managed_paths(root, scopes):
151
+ if path.name not in files:
152
+ stale.append(
153
+ SecureDestination(path, trusted_root, f"Cline stale {path.name}")
154
+ )
155
+
156
+ def apply(transaction: SecureTransaction) -> None:
157
+ for destination in active:
158
+ root_files = outputs[destination.path.parent]
159
+ transaction.atomic_write(
160
+ destination,
161
+ root_files[destination.path.name],
162
+ 0o644,
163
+ )
164
+ for destination in stale:
165
+ transaction.unlink(destination)
166
+
167
+ run_secure_transaction(active + stale, apply)
168
+ for root, files in outputs.items():
169
+ for name in files:
170
+ print(f" Generated: {(root / name).relative_to(target)}")
171
+
172
+
173
+ def generate(
174
+ target_dir: Path,
175
+ *,
176
+ language_modules: list[str] | None = None,
177
+ rules_dir: Path | None = None,
178
+ cleanup: bool = True,
179
+ emit_workflows: bool = True,
180
+ managed_scopes: tuple[str, ...] = (STANDARD_SCOPE,),
181
+ output_root: Path | None = None,
182
+ ) -> None:
88
183
  """Write Cline rule files.
89
184
 
90
- By default writes project-local ``target_dir/.clinerules/*.md``. When
91
- ``output_root`` is provided, writes directly into that directory; this is
92
- used for Cline's documented global rules directory.
185
+ By default writes project-local ``target_dir/.cline/rules/*.md`` and the
186
+ extension-compatible ``target_dir/.clinerules/*.md``. When ``output_root``
187
+ is provided, writes only into that directory so installers can target one
188
+ documented global rules root at a time.
93
189
  """
94
- # Migrate: if .clinerules exists as a single file, remove it so the
95
- # directory can be created (Cline 3.7+ uses directory format).
96
- if output_root is None:
97
- clinerules = target_dir / ".clinerules"
98
- if clinerules.is_file():
99
- clinerules.unlink()
190
+ target, trusted_root = _trusted_target(target_dir)
191
+ legacy_clinerules = target / ".clinerules"
192
+ if legacy_clinerules.is_symlink():
193
+ raise RuntimeError(f"Unsafe symlinked Cline rules root: {legacy_clinerules}")
194
+ preserve_legacy_file = output_root is None and legacy_clinerules.is_file()
100
195
 
101
196
  rules: dict[str, callable] = dict(STANDARD_RULES)
102
197
  # Replace the testing rule with a conditional variant so it only
@@ -112,38 +207,110 @@ def generate(target_dir: Path, *,
112
207
  # "common" spans all languages — apply unconditionally.
113
208
  rules[filename] = content_fn
114
209
  continue
115
- rules[filename] = (lambda fn, l: lambda: _wrap_language_rule(fn(), l))(
116
- content_fn, lang,
117
- )
210
+ rules[filename] = (
211
+ lambda fn, language: lambda: _wrap_language_rule(fn(), language)
212
+ )(content_fn, lang)
118
213
 
119
214
  rules.update(build_registered_rules(rules_dir))
120
215
 
121
- root = output_root.parent if output_root is not None else target_dir
122
- subdir = output_root.name if output_root is not None else ".clinerules"
123
- write_rules(
124
- root,
125
- rules,
126
- subdir,
216
+ rendered_rules = _render_rules(rules)
217
+ if output_root is None:
218
+ rule_roots = [target / ".cline" / "rules"]
219
+ if not preserve_legacy_file:
220
+ rule_roots.append(target / ".clinerules")
221
+ else:
222
+ rule_roots = [_output_root(target, output_root)]
223
+ outputs = {root: rendered_rules for root in rule_roots}
224
+ if emit_workflows and output_root is None and not preserve_legacy_file:
225
+ outputs[target / ".clinerules" / "workflows"] = _render_rules(
226
+ dict(STANDARD_WORKFLOWS)
227
+ )
228
+ _preflight_roots(target, list(outputs), trusted_root)
229
+ _write_transaction(
230
+ target,
231
+ trusted_root,
232
+ outputs,
127
233
  cleanup=cleanup,
128
234
  managed_scopes=managed_scopes,
129
235
  )
130
236
 
131
- if emit_workflows and output_root is None:
132
- _write_workflows(target_dir, cleanup=cleanup)
133
237
 
238
+ def _managed_roots(
239
+ target: Path,
240
+ output_roots: tuple[Path, ...] | None,
241
+ include_workflows: bool,
242
+ ) -> list[Path]:
243
+ if output_roots is not None:
244
+ return [_output_root(target, root) for root in output_roots]
245
+ roots = [target / ".cline" / "rules"]
246
+ legacy = target / ".clinerules"
247
+ if legacy.is_symlink():
248
+ raise RuntimeError(f"Unsafe symlinked Cline rules root: {legacy}")
249
+ if not legacy.is_file():
250
+ roots.append(legacy)
251
+ if include_workflows:
252
+ roots.append(legacy / "workflows")
253
+ return roots
134
254
 
135
- def _write_workflows(target_dir: Path, *, cleanup: bool = True) -> None:
136
- """Write ``.clinerules/workflows/*.md`` files (Cline slash-invocable)."""
137
- workflows_dir = target_dir / ".clinerules" / "workflows"
138
- workflows_dir.mkdir(parents=True, exist_ok=True)
139
255
 
140
- if cleanup:
141
- # Only touch ai-toolkit-* files; never remove user workflows.
142
- cleanup_stale(workflows_dir, set(STANDARD_WORKFLOWS.keys()))
256
+ def managed_files(
257
+ target_dir: Path,
258
+ *,
259
+ output_roots: tuple[Path, ...] | None = None,
260
+ include_workflows: bool = True,
261
+ ) -> list[Path]:
262
+ """List Cline rule artifacts owned by ai-toolkit."""
263
+ target, trusted_root = _trusted_target(target_dir)
264
+ roots = _managed_roots(target, output_roots, include_workflows)
265
+ _preflight_roots(target, roots, trusted_root)
266
+ paths = [
267
+ path
268
+ for root in roots
269
+ for path in _managed_paths(root, {STANDARD_SCOPE, "lang", "custom"})
270
+ ]
271
+ destinations = [
272
+ SecureDestination(path, trusted_root, f"Cline managed {path.name}")
273
+ for path in paths
274
+ ]
275
+ if not destinations:
276
+ return []
277
+ transaction = SecureTransaction(destinations)
278
+ try:
279
+ return [
280
+ destination.path
281
+ for destination in destinations
282
+ if transaction.initial_content(destination) is not None
283
+ ]
284
+ finally:
285
+ transaction.close()
286
+
287
+
288
+ def cleanup(
289
+ target_dir: Path,
290
+ *,
291
+ output_roots: tuple[Path, ...] | None = None,
292
+ include_workflows: bool = True,
293
+ ) -> int:
294
+ """Remove only ai-toolkit-managed Cline rule and workflow files."""
295
+ files = managed_files(
296
+ target_dir,
297
+ output_roots=output_roots,
298
+ include_workflows=include_workflows,
299
+ )
300
+ if not files:
301
+ return 0
302
+ _, trusted_root = _trusted_target(target_dir)
303
+ destinations = [
304
+ SecureDestination(path, trusted_root, f"Cline managed {path.name}")
305
+ for path in files
306
+ ]
307
+
308
+ def apply(transaction: SecureTransaction) -> None:
309
+ for destination in destinations:
310
+ transaction.unlink(destination)
143
311
 
144
- for filename, content_fn in STANDARD_WORKFLOWS.items():
145
- (workflows_dir / filename).write_text(content_fn(), encoding="utf-8")
146
- print(f" Generated: .clinerules/workflows/{filename}")
312
+ run_secure_transaction(destinations, apply)
313
+ return len(files)
147
314
 
148
315
 
149
316
  def main() -> None: