@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,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
|
|
6
|
+
"""Generate native Cline rules plus extension-compatible rule files.
|
|
7
7
|
|
|
8
|
-
Cline reads rules
|
|
9
|
-
|
|
10
|
-
``.clinerules``
|
|
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
|
-
|
|
39
|
+
rule_scope,
|
|
40
40
|
rule_testing,
|
|
41
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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/.
|
|
91
|
-
``output_root``
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
if
|
|
97
|
-
|
|
98
|
-
|
|
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] = (
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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:
|