@softspark/ai-toolkit 4.14.0 → 4.15.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 +35 -0
- package/README.md +11 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- package/app/agents/fact-checker.md +1 -1
- package/app/hooks/_search-capability.sh +3 -2
- package/app/hooks/stop-search-check.sh +2 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
- package/kb/procedures/maintenance-sop.md +26 -13
- package/kb/procedures/release-verification-sop.md +41 -36
- package/kb/reference/architecture-overview.md +23 -7
- package/kb/reference/codex-cli-compatibility.md +96 -36
- package/kb/reference/extension-api.md +52 -9
- package/kb/reference/global-install-model.md +53 -21
- package/kb/reference/hooks-catalog.md +44 -8
- package/kb/reference/mcp-editor-compatibility.md +27 -6
- package/kb/reference/mcp-templates.md +12 -6
- package/kb/reference/opencode-compatibility.md +13 -7
- package/kb/reference/plugin-pack-conventions.md +7 -7
- package/kb/reference/skills-catalog.md +3 -3
- package/kb/reference/supported-tools-registry.md +19 -17
- package/kb/reference/windows-support.md +26 -3
- package/llms-full.txt +443 -180
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +448 -198
- package/scripts/dir_rules_shared.py +2 -11
- package/scripts/ecosystem_tools.json +29 -8
- package/scripts/emission.py +5 -91
- package/scripts/generate_agents_md.py +4 -87
- package/scripts/generate_codex.py +5 -95
- package/scripts/generate_codex_agents.py +242 -0
- package/scripts/generate_codex_hooks.py +648 -55
- package/scripts/generate_codex_skills.py +15 -6
- package/scripts/generate_copilot.py +771 -74
- package/scripts/generate_copilot_hooks.py +606 -0
- package/scripts/generate_cursor_hooks.py +453 -121
- package/scripts/generate_opencode_commands.py +4 -6
- package/scripts/inject_hook_cli.py +770 -205
- package/scripts/injection.py +102 -23
- package/scripts/install_steps/ai_tools.py +123 -83
- package/scripts/instruction_core.py +95 -0
- package/scripts/mcp_editors.py +934 -80
- package/scripts/mcp_manager.py +46 -26
- package/scripts/plugin.py +291 -114
- package/scripts/secure_fs.py +538 -0
- package/scripts/uninstall.py +1279 -208
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate native, self-contained GitHub Copilot hooks.
|
|
3
|
+
|
|
4
|
+
Repository installs write ``.github/hooks/ai-toolkit.json`` plus a managed
|
|
5
|
+
runtime below ``.github/hooks/ai-toolkit/``. User installs write the same
|
|
6
|
+
artifacts below the active Copilot configuration root (``COPILOT_HOME`` or
|
|
7
|
+
``~/.copilot``). The generated commands do not depend on the ai-toolkit
|
|
8
|
+
checkout after installation.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
OWNER_KEY = "AI_TOOLKIT_HOOK_OWNER"
|
|
23
|
+
OWNER_VALUE = "ai-toolkit"
|
|
24
|
+
SCRIPT_MARKER = "# ai-toolkit-managed: github-copilot-hook"
|
|
25
|
+
CONFIG_NAME = "ai-toolkit.json"
|
|
26
|
+
SCRIPT_NAME = "copilot_hook.py"
|
|
27
|
+
|
|
28
|
+
SUPPORTED_EVENTS = frozenset({
|
|
29
|
+
"agentStop",
|
|
30
|
+
"errorOccurred",
|
|
31
|
+
"notification",
|
|
32
|
+
"permissionRequest",
|
|
33
|
+
"postToolUse",
|
|
34
|
+
"postToolUseFailure",
|
|
35
|
+
"preCompact",
|
|
36
|
+
"preToolUse",
|
|
37
|
+
"sessionEnd",
|
|
38
|
+
"sessionStart",
|
|
39
|
+
"subagentStart",
|
|
40
|
+
"subagentStop",
|
|
41
|
+
"userPromptSubmitted",
|
|
42
|
+
})
|
|
43
|
+
MATCHER_EVENTS = frozenset({
|
|
44
|
+
"notification",
|
|
45
|
+
"permissionRequest",
|
|
46
|
+
"postToolUse",
|
|
47
|
+
"preCompact",
|
|
48
|
+
"preToolUse",
|
|
49
|
+
"subagentStart",
|
|
50
|
+
})
|
|
51
|
+
COMMAND_KEYS = frozenset({
|
|
52
|
+
"type",
|
|
53
|
+
"bash",
|
|
54
|
+
"command",
|
|
55
|
+
"powershell",
|
|
56
|
+
"cwd",
|
|
57
|
+
"env",
|
|
58
|
+
"timeout",
|
|
59
|
+
"timeoutSec",
|
|
60
|
+
"matcher",
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
HOOK_RUNTIME = r'''#!/usr/bin/env python3
|
|
65
|
+
# ai-toolkit-managed: github-copilot-hook
|
|
66
|
+
"""Self-contained runtime for native GitHub Copilot hooks."""
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import json
|
|
70
|
+
import os
|
|
71
|
+
import re
|
|
72
|
+
import shutil
|
|
73
|
+
import subprocess
|
|
74
|
+
import sys
|
|
75
|
+
import tempfile
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
from typing import Any
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
MAX_QUALITY_BLOCKS = 3
|
|
81
|
+
MAX_OUTPUT_CHARS = 2_000
|
|
82
|
+
|
|
83
|
+
DESTRUCTIVE_PATTERNS = tuple(re.compile(pattern, re.IGNORECASE) for pattern in (
|
|
84
|
+
r"\brm\s+(?:-[rRf]{2,}|-r\s+-f|-f\s+-r|--recursive|--force)\b",
|
|
85
|
+
r"\bsudo\s+rm\b",
|
|
86
|
+
r"\b(?:xargs\s+rm|find\s+.+(?:-delete|-exec\s+rm))\b",
|
|
87
|
+
r"\bDROP\s+(?:TABLE|DATABASE|SCHEMA|INDEX)\b",
|
|
88
|
+
r"\bTRUNCATE\s+",
|
|
89
|
+
r"\bDELETE\s+FROM\s+\S+\s*(?:;|$|WHERE\s+1)\b",
|
|
90
|
+
r"\b(?:mkfs|shred)\b",
|
|
91
|
+
r"\bdd\s+if=",
|
|
92
|
+
r"\bgit\s+push\s+.*(?:--force(?:\s|$)|-f(?:\s|$))",
|
|
93
|
+
r"\bgit\s+(?:reset\s+--hard|clean\s+-[a-z]*f|branch\s+-D)\b",
|
|
94
|
+
r"\bchmod\s+(?:-R\s+)?(?:777|000)\b",
|
|
95
|
+
r"\bdocker\s+(?:system\s+prune|rm\s+-f|rmi\s+-f)\b",
|
|
96
|
+
r"\bkubectl\s+delete\s+(?:namespace|ns|all|node)\b",
|
|
97
|
+
r"\bterraform\s+destroy\b",
|
|
98
|
+
r"\bsystemctl\s+(?:stop|disable)\s+",
|
|
99
|
+
r">\s*/dev/sd[a-z]",
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _payload() -> dict[str, Any]:
|
|
104
|
+
raw = sys.stdin.read()
|
|
105
|
+
if not raw.strip():
|
|
106
|
+
return {}
|
|
107
|
+
try:
|
|
108
|
+
value = json.loads(raw)
|
|
109
|
+
except json.JSONDecodeError as error:
|
|
110
|
+
print(f"ai-toolkit Copilot hook skipped invalid JSON: {error}", file=sys.stderr)
|
|
111
|
+
return {}
|
|
112
|
+
return value if isinstance(value, dict) else {}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _emit(value: dict[str, Any]) -> None:
|
|
116
|
+
print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _session_id(payload: dict[str, Any]) -> str:
|
|
120
|
+
raw = str(payload.get("sessionId") or payload.get("session_id") or "default")
|
|
121
|
+
return re.sub(r"[^A-Za-z0-9_.-]", "_", raw)[:160] or "default"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _state_path(payload: dict[str, Any]) -> Path:
|
|
125
|
+
configured = os.environ.get("AI_TOOLKIT_COPILOT_STATE_DIR")
|
|
126
|
+
root = Path(configured) if configured else (
|
|
127
|
+
Path(tempfile.gettempdir()) / "ai-toolkit-copilot-hooks"
|
|
128
|
+
)
|
|
129
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
return root / f"quality-{_session_id(payload)}.count"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _clear_quality_state(payload: dict[str, Any]) -> None:
|
|
134
|
+
try:
|
|
135
|
+
_state_path(payload).unlink(missing_ok=True)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _increment_quality_failures(payload: dict[str, Any]) -> int:
|
|
141
|
+
path = _state_path(payload)
|
|
142
|
+
try:
|
|
143
|
+
current = int(path.read_text(encoding="utf-8").strip()) if path.is_file() else 0
|
|
144
|
+
except (OSError, ValueError):
|
|
145
|
+
current = 0
|
|
146
|
+
current += 1
|
|
147
|
+
try:
|
|
148
|
+
fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=".quality-", suffix=".tmp")
|
|
149
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
150
|
+
handle.write(f"{current}\n")
|
|
151
|
+
handle.flush()
|
|
152
|
+
os.fsync(handle.fileno())
|
|
153
|
+
os.replace(temp_name, path)
|
|
154
|
+
except OSError:
|
|
155
|
+
try:
|
|
156
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
157
|
+
except (OSError, UnboundLocalError):
|
|
158
|
+
pass
|
|
159
|
+
return current
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _tool_args(payload: dict[str, Any]) -> Any:
|
|
163
|
+
return payload.get("toolArgs", payload.get("tool_input", {}))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _command_text(arguments: Any) -> str:
|
|
167
|
+
if isinstance(arguments, str):
|
|
168
|
+
return arguments
|
|
169
|
+
if not isinstance(arguments, dict):
|
|
170
|
+
return ""
|
|
171
|
+
for key in ("command", "commandLine", "command_line", "script", "code"):
|
|
172
|
+
value = arguments.get(key)
|
|
173
|
+
if isinstance(value, str):
|
|
174
|
+
return value
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _all_strings(value: Any) -> list[str]:
|
|
179
|
+
if isinstance(value, str):
|
|
180
|
+
return [value]
|
|
181
|
+
if isinstance(value, dict):
|
|
182
|
+
result: list[str] = []
|
|
183
|
+
for item in value.values():
|
|
184
|
+
result.extend(_all_strings(item))
|
|
185
|
+
return result
|
|
186
|
+
if isinstance(value, list):
|
|
187
|
+
result = []
|
|
188
|
+
for item in value:
|
|
189
|
+
result.extend(_all_strings(item))
|
|
190
|
+
return result
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _destructive_reason(command: str) -> str | None:
|
|
195
|
+
normalized = " ".join(command.replace("\\", "").split())
|
|
196
|
+
if not normalized:
|
|
197
|
+
return None
|
|
198
|
+
if not re.search(r"&&|\|\||;|\|", normalized):
|
|
199
|
+
if re.match(r"\s*(?:echo|printf|git\s+(?:commit|tag))(?:\s|$)", normalized):
|
|
200
|
+
return None
|
|
201
|
+
normalized = re.sub(r"--force-with-lease(?:=\S+)?|--force-if-includes", "", normalized)
|
|
202
|
+
if any(pattern.search(normalized) for pattern in DESTRUCTIVE_PATTERNS):
|
|
203
|
+
return "Potentially destructive command requires explicit user review."
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _wrong_home_reason(arguments: Any) -> str | None:
|
|
208
|
+
home = Path.home()
|
|
209
|
+
actual_user = home.name
|
|
210
|
+
if not actual_user:
|
|
211
|
+
return None
|
|
212
|
+
path_pattern = re.compile(r"/(?:Users|home)/([^/\s'\"]+)")
|
|
213
|
+
for text in _all_strings(arguments):
|
|
214
|
+
for match in path_pattern.finditer(text):
|
|
215
|
+
if match.group(1) != actual_user:
|
|
216
|
+
return (
|
|
217
|
+
f"Absolute path names user '{match.group(1)}', but the active "
|
|
218
|
+
f"home belongs to '{actual_user}'. Use $HOME or the correct path."
|
|
219
|
+
)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _pre_tool_use(payload: dict[str, Any]) -> None:
|
|
224
|
+
arguments = _tool_args(payload)
|
|
225
|
+
reason = _wrong_home_reason(arguments)
|
|
226
|
+
tool_name = str(payload.get("toolName") or payload.get("tool_name") or "")
|
|
227
|
+
if reason is None and tool_name.lower() in {"bash", "powershell"}:
|
|
228
|
+
reason = _destructive_reason(_command_text(arguments))
|
|
229
|
+
if reason:
|
|
230
|
+
_emit({"permissionDecision": "deny", "permissionDecisionReason": reason})
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _quality_command(cwd: Path) -> tuple[str, list[str]] | None:
|
|
234
|
+
if (cwd / "pyproject.toml").is_file() or (cwd / "setup.py").is_file():
|
|
235
|
+
if shutil.which("ruff"):
|
|
236
|
+
return "ruff check", ["ruff", "check", "."]
|
|
237
|
+
if (cwd / "package.json").is_file() and (cwd / "tsconfig.json").is_file():
|
|
238
|
+
local_tsc = cwd / "node_modules" / ".bin" / "tsc"
|
|
239
|
+
if local_tsc.is_file():
|
|
240
|
+
return "TypeScript typecheck", [str(local_tsc), "--noEmit"]
|
|
241
|
+
if shutil.which("tsc"):
|
|
242
|
+
return "TypeScript typecheck", ["tsc", "--noEmit"]
|
|
243
|
+
if (cwd / "pubspec.yaml").is_file() and shutil.which("dart"):
|
|
244
|
+
return "Dart analysis", ["dart", "analyze"]
|
|
245
|
+
if (cwd / "go.mod").is_file() and shutil.which("go"):
|
|
246
|
+
return "Go vet", ["go", "vet", "./..."]
|
|
247
|
+
phpstan = cwd / "vendor" / "bin" / "phpstan"
|
|
248
|
+
if (cwd / "composer.json").is_file() and phpstan.is_file():
|
|
249
|
+
return "PHPStan", [str(phpstan), "analyse"]
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _agent_stop(payload: dict[str, Any]) -> None:
|
|
254
|
+
cwd_value = payload.get("cwd") or os.getcwd()
|
|
255
|
+
cwd = Path(str(cwd_value))
|
|
256
|
+
if not cwd.is_dir():
|
|
257
|
+
return
|
|
258
|
+
selected = _quality_command(cwd)
|
|
259
|
+
if selected is None:
|
|
260
|
+
_clear_quality_state(payload)
|
|
261
|
+
return
|
|
262
|
+
label, command = selected
|
|
263
|
+
try:
|
|
264
|
+
result = subprocess.run(
|
|
265
|
+
command,
|
|
266
|
+
cwd=cwd,
|
|
267
|
+
capture_output=True,
|
|
268
|
+
text=True,
|
|
269
|
+
timeout=110,
|
|
270
|
+
check=False,
|
|
271
|
+
)
|
|
272
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
273
|
+
print(f"ai-toolkit Copilot quality hook skipped {label}: {error}", file=sys.stderr)
|
|
274
|
+
return
|
|
275
|
+
if result.returncode == 0:
|
|
276
|
+
_clear_quality_state(payload)
|
|
277
|
+
return
|
|
278
|
+
failures = _increment_quality_failures(payload)
|
|
279
|
+
detail = (result.stdout + "\n" + result.stderr).strip()[-MAX_OUTPUT_CHARS:]
|
|
280
|
+
if failures >= MAX_QUALITY_BLOCKS:
|
|
281
|
+
print(
|
|
282
|
+
f"ai-toolkit circuit breaker: {label} failed {failures} times; "
|
|
283
|
+
"allowing stop so the agent can report the blocker.",
|
|
284
|
+
file=sys.stderr,
|
|
285
|
+
)
|
|
286
|
+
_clear_quality_state(payload)
|
|
287
|
+
return
|
|
288
|
+
reason = f"{label} failed. Fix the errors and verify again before finishing."
|
|
289
|
+
if detail:
|
|
290
|
+
reason += f"\n\n{detail}"
|
|
291
|
+
_emit({"decision": "block", "reason": reason})
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def main() -> None:
|
|
295
|
+
event = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
296
|
+
payload = _payload()
|
|
297
|
+
if event == "session-start":
|
|
298
|
+
_clear_quality_state(payload)
|
|
299
|
+
_emit({
|
|
300
|
+
"additionalContext": (
|
|
301
|
+
"AI Toolkit: follow the repository and personal Copilot "
|
|
302
|
+
"instructions, use relevant skills, keep tests and docs aligned, "
|
|
303
|
+
"and verify evidence before claiming completion."
|
|
304
|
+
)
|
|
305
|
+
})
|
|
306
|
+
elif event == "pre-tool-use":
|
|
307
|
+
_pre_tool_use(payload)
|
|
308
|
+
elif event == "post-tool-use":
|
|
309
|
+
_emit({
|
|
310
|
+
"additionalContext": (
|
|
311
|
+
"A file-changing tool completed. Run the relevant validation and "
|
|
312
|
+
"tests, and update affected documentation before finishing."
|
|
313
|
+
)
|
|
314
|
+
})
|
|
315
|
+
elif event == "post-tool-use-failure":
|
|
316
|
+
print(
|
|
317
|
+
"The tool failed. Inspect the concrete error, gather evidence, and "
|
|
318
|
+
"apply the smallest safe correction before retrying."
|
|
319
|
+
)
|
|
320
|
+
raise SystemExit(2)
|
|
321
|
+
elif event == "subagent-start":
|
|
322
|
+
_emit({
|
|
323
|
+
"additionalContext": (
|
|
324
|
+
"Stay within the delegated scope, cite concrete evidence, and return "
|
|
325
|
+
"explicit validation notes with any edits."
|
|
326
|
+
)
|
|
327
|
+
})
|
|
328
|
+
elif event == "agent-stop":
|
|
329
|
+
_agent_stop(payload)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
try:
|
|
334
|
+
main()
|
|
335
|
+
except Exception as error: # Never turn an adapter bug into an agent loop.
|
|
336
|
+
print(f"ai-toolkit Copilot hook failed safely: {error}", file=sys.stderr)
|
|
337
|
+
'''
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
HOOK_DEFINITIONS: tuple[tuple[str, str, str | None, int], ...] = (
|
|
341
|
+
("sessionStart", "session-start", None, 10),
|
|
342
|
+
("preToolUse", "pre-tool-use", None, 10),
|
|
343
|
+
("postToolUse", "post-tool-use", "create|edit", 10),
|
|
344
|
+
("postToolUseFailure", "post-tool-use-failure", None, 10),
|
|
345
|
+
("subagentStart", "subagent-start", None, 10),
|
|
346
|
+
("agentStop", "agent-stop", None, 120),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _powershell_quote(value: str) -> str:
|
|
351
|
+
return "'" + value.replace("'", "''") + "'"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _commands(script_path: Path, *, project_install: bool, action: str) -> tuple[str, str]:
|
|
355
|
+
if project_install:
|
|
356
|
+
relative = ".github/hooks/ai-toolkit/copilot_hook.py"
|
|
357
|
+
return (
|
|
358
|
+
f"python3 {shlex.quote(relative)} {shlex.quote(action)}",
|
|
359
|
+
f"python {_powershell_quote(relative)} {_powershell_quote(action)}",
|
|
360
|
+
)
|
|
361
|
+
absolute = str(script_path.absolute())
|
|
362
|
+
return (
|
|
363
|
+
f"python3 {shlex.quote(absolute)} {shlex.quote(action)}",
|
|
364
|
+
f"python {_powershell_quote(absolute)} {_powershell_quote(action)}",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def build_hooks_json(script_path: Path, *, project_install: bool) -> dict[str, Any]:
|
|
369
|
+
hooks: dict[str, list[dict[str, Any]]] = {}
|
|
370
|
+
for event, action, matcher, timeout in HOOK_DEFINITIONS:
|
|
371
|
+
bash, powershell = _commands(
|
|
372
|
+
script_path,
|
|
373
|
+
project_install=project_install,
|
|
374
|
+
action=action,
|
|
375
|
+
)
|
|
376
|
+
entry: dict[str, Any] = {
|
|
377
|
+
"type": "command",
|
|
378
|
+
"bash": bash,
|
|
379
|
+
"powershell": powershell,
|
|
380
|
+
"cwd": ".",
|
|
381
|
+
"env": {OWNER_KEY: OWNER_VALUE},
|
|
382
|
+
"timeoutSec": timeout,
|
|
383
|
+
}
|
|
384
|
+
if matcher is not None:
|
|
385
|
+
entry["matcher"] = matcher
|
|
386
|
+
hooks.setdefault(event, []).append(entry)
|
|
387
|
+
data = {"version": 1, "hooks": hooks}
|
|
388
|
+
_validate_document(data)
|
|
389
|
+
return data
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _validate_document(data: Any) -> None:
|
|
393
|
+
if not isinstance(data, dict) or set(data) - {"version", "hooks", "disableAllHooks"}:
|
|
394
|
+
raise ValueError("Copilot hooks file has unsupported top-level fields")
|
|
395
|
+
if data.get("version") != 1 or not isinstance(data.get("hooks"), dict):
|
|
396
|
+
raise ValueError("Copilot hooks file must contain version 1 and a hooks object")
|
|
397
|
+
unsupported = set(data["hooks"]) - SUPPORTED_EVENTS
|
|
398
|
+
if unsupported:
|
|
399
|
+
raise ValueError(f"Unsupported Copilot hook events: {sorted(unsupported)}")
|
|
400
|
+
for event, entries in data["hooks"].items():
|
|
401
|
+
if not isinstance(entries, list) or not entries:
|
|
402
|
+
raise ValueError(f"Copilot hook event {event} must contain entries")
|
|
403
|
+
for entry in entries:
|
|
404
|
+
_validate_entry(event, entry)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _validate_entry(event: str, entry: Any) -> None:
|
|
408
|
+
if not isinstance(entry, dict) or set(entry) - COMMAND_KEYS:
|
|
409
|
+
raise ValueError(f"Invalid Copilot command hook for {event}")
|
|
410
|
+
if entry.get("type", "command") != "command":
|
|
411
|
+
raise ValueError(f"Copilot {event} hook must use type=command")
|
|
412
|
+
if not any(isinstance(entry.get(key), str) and entry[key] for key in (
|
|
413
|
+
"bash", "powershell", "command"
|
|
414
|
+
)):
|
|
415
|
+
raise ValueError(f"Copilot {event} hook needs a shell command")
|
|
416
|
+
if "matcher" in entry:
|
|
417
|
+
if event not in MATCHER_EVENTS or not isinstance(entry["matcher"], str):
|
|
418
|
+
raise ValueError(f"Copilot event {event} does not accept this matcher")
|
|
419
|
+
re.compile(entry["matcher"])
|
|
420
|
+
if "cwd" in entry and not isinstance(entry["cwd"], str):
|
|
421
|
+
raise ValueError(f"Copilot {event} cwd must be a string")
|
|
422
|
+
env = entry.get("env", {})
|
|
423
|
+
if not isinstance(env, dict) or any(
|
|
424
|
+
not isinstance(key, str) or not isinstance(value, str)
|
|
425
|
+
for key, value in env.items()
|
|
426
|
+
):
|
|
427
|
+
raise ValueError(f"Copilot {event} env must contain strings")
|
|
428
|
+
for key in ("timeout", "timeoutSec"):
|
|
429
|
+
if key in entry and (
|
|
430
|
+
not isinstance(entry[key], (int, float)) or isinstance(entry[key], bool)
|
|
431
|
+
or entry[key] <= 0
|
|
432
|
+
):
|
|
433
|
+
raise ValueError(f"Copilot {event} {key} must be positive")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _is_managed_config(path: Path) -> bool:
|
|
437
|
+
if path.is_symlink() or not path.is_file():
|
|
438
|
+
return False
|
|
439
|
+
try:
|
|
440
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
441
|
+
_validate_document(data)
|
|
442
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
443
|
+
return False
|
|
444
|
+
entries = [entry for values in data["hooks"].values() for entry in values]
|
|
445
|
+
return bool(entries) and all(
|
|
446
|
+
entry.get("env", {}).get(OWNER_KEY) == OWNER_VALUE
|
|
447
|
+
for entry in entries
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _is_managed_script(path: Path) -> bool:
|
|
452
|
+
if path.is_symlink() or not path.is_file():
|
|
453
|
+
return False
|
|
454
|
+
try:
|
|
455
|
+
return SCRIPT_MARKER in path.read_text(encoding="utf-8")[:256]
|
|
456
|
+
except (OSError, UnicodeError):
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _assert_safe_paths(paths: list[tuple[Path, str]]) -> None:
|
|
461
|
+
for path, label in paths:
|
|
462
|
+
if path.is_symlink():
|
|
463
|
+
raise RuntimeError(f"Refusing symlinked Copilot {label}: {path}")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _assert_collisions(config_path: Path, script_path: Path) -> None:
|
|
467
|
+
if config_path.exists() and not _is_managed_config(config_path):
|
|
468
|
+
raise RuntimeError(f"Refusing user-owned Copilot hook config collision: {config_path}")
|
|
469
|
+
if script_path.exists() and not _is_managed_script(script_path):
|
|
470
|
+
raise RuntimeError(f"Refusing user-owned Copilot hook runtime collision: {script_path}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _stage_file(destination: Path, content: bytes, mode: int) -> Path:
|
|
474
|
+
fd, temp_name = tempfile.mkstemp(
|
|
475
|
+
dir=destination.parent,
|
|
476
|
+
prefix=f".{destination.name}.",
|
|
477
|
+
suffix=".tmp",
|
|
478
|
+
)
|
|
479
|
+
temp_path = Path(temp_name)
|
|
480
|
+
try:
|
|
481
|
+
with os.fdopen(fd, "wb") as handle:
|
|
482
|
+
fd = -1
|
|
483
|
+
handle.write(content)
|
|
484
|
+
handle.flush()
|
|
485
|
+
os.fsync(handle.fileno())
|
|
486
|
+
os.chmod(temp_path, mode)
|
|
487
|
+
return temp_path
|
|
488
|
+
except Exception:
|
|
489
|
+
if fd >= 0:
|
|
490
|
+
os.close(fd)
|
|
491
|
+
temp_path.unlink(missing_ok=True)
|
|
492
|
+
raise
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _write_transaction(outputs: list[tuple[Path, bytes, int]]) -> None:
|
|
496
|
+
staged: list[tuple[Path, Path]] = []
|
|
497
|
+
backups: dict[Path, Path] = {}
|
|
498
|
+
applied: list[Path] = []
|
|
499
|
+
try:
|
|
500
|
+
for destination, content, mode in outputs:
|
|
501
|
+
staged.append((_stage_file(destination, content, mode), destination))
|
|
502
|
+
for _, destination in staged:
|
|
503
|
+
if destination.exists():
|
|
504
|
+
mode = destination.stat().st_mode & 0o777
|
|
505
|
+
backups[destination] = _stage_file(
|
|
506
|
+
destination,
|
|
507
|
+
destination.read_bytes(),
|
|
508
|
+
mode,
|
|
509
|
+
)
|
|
510
|
+
for temp_path, destination in staged:
|
|
511
|
+
if destination.is_symlink():
|
|
512
|
+
raise RuntimeError(f"Copilot hook path became a symlink: {destination}")
|
|
513
|
+
os.replace(temp_path, destination)
|
|
514
|
+
applied.append(destination)
|
|
515
|
+
for directory in {destination.parent for _, destination in staged}:
|
|
516
|
+
try:
|
|
517
|
+
descriptor = os.open(directory, os.O_RDONLY)
|
|
518
|
+
try:
|
|
519
|
+
os.fsync(descriptor)
|
|
520
|
+
finally:
|
|
521
|
+
os.close(descriptor)
|
|
522
|
+
except OSError:
|
|
523
|
+
pass
|
|
524
|
+
except Exception as error:
|
|
525
|
+
rollback_errors: list[Exception] = []
|
|
526
|
+
for destination in reversed(applied):
|
|
527
|
+
backup = backups.get(destination)
|
|
528
|
+
try:
|
|
529
|
+
if backup is None:
|
|
530
|
+
destination.unlink(missing_ok=True)
|
|
531
|
+
else:
|
|
532
|
+
os.replace(backup, destination)
|
|
533
|
+
except Exception as rollback_error: # pragma: no cover
|
|
534
|
+
rollback_errors.append(rollback_error)
|
|
535
|
+
if rollback_errors:
|
|
536
|
+
raise RuntimeError(
|
|
537
|
+
f"Copilot hook update failed and rollback was incomplete: {rollback_errors}"
|
|
538
|
+
) from error
|
|
539
|
+
raise
|
|
540
|
+
finally:
|
|
541
|
+
for temp_path, _ in staged:
|
|
542
|
+
temp_path.unlink(missing_ok=True)
|
|
543
|
+
for backup in backups.values():
|
|
544
|
+
backup.unlink(missing_ok=True)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def copilot_home(home: Path | None = None) -> Path:
|
|
548
|
+
"""Return the active user configuration root, honoring ``COPILOT_HOME``."""
|
|
549
|
+
configured = os.environ.get("COPILOT_HOME")
|
|
550
|
+
if configured:
|
|
551
|
+
return Path(configured).expanduser().absolute()
|
|
552
|
+
base = Path.home() if home is None else Path(home).expanduser().absolute()
|
|
553
|
+
return base / ".copilot"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def generate(target_dir: Path, *, config_root: Path | None = None) -> None:
|
|
557
|
+
"""Generate repository hooks, or user hooks when ``config_root`` is set."""
|
|
558
|
+
target_dir = Path(target_dir).expanduser().absolute()
|
|
559
|
+
project_install = config_root is None
|
|
560
|
+
customization_root = (
|
|
561
|
+
target_dir / ".github" if project_install
|
|
562
|
+
else Path(config_root).expanduser().absolute()
|
|
563
|
+
)
|
|
564
|
+
hooks_dir = customization_root / "hooks"
|
|
565
|
+
assets_dir = hooks_dir / "ai-toolkit"
|
|
566
|
+
config_path = hooks_dir / CONFIG_NAME
|
|
567
|
+
script_path = assets_dir / SCRIPT_NAME
|
|
568
|
+
safe_paths = [
|
|
569
|
+
(target_dir, "target root"),
|
|
570
|
+
(customization_root, "customization root"),
|
|
571
|
+
(hooks_dir, "hooks directory"),
|
|
572
|
+
(assets_dir, "hook assets directory"),
|
|
573
|
+
(config_path, "hook config"),
|
|
574
|
+
(script_path, "hook runtime"),
|
|
575
|
+
]
|
|
576
|
+
_assert_safe_paths(safe_paths)
|
|
577
|
+
for directory in (customization_root, hooks_dir, assets_dir):
|
|
578
|
+
if directory.exists() and not directory.is_dir():
|
|
579
|
+
raise RuntimeError(f"Copilot hook path is not a directory: {directory}")
|
|
580
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
581
|
+
_assert_safe_paths(safe_paths)
|
|
582
|
+
_assert_collisions(config_path, script_path)
|
|
583
|
+
|
|
584
|
+
data = build_hooks_json(script_path, project_install=project_install)
|
|
585
|
+
config_bytes = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode()
|
|
586
|
+
_write_transaction([
|
|
587
|
+
(script_path, HOOK_RUNTIME.encode(), 0o755),
|
|
588
|
+
(config_path, config_bytes, 0o644),
|
|
589
|
+
])
|
|
590
|
+
label = ".github/hooks" if project_install else str(hooks_dir)
|
|
591
|
+
print(f" Generated: {label}/{CONFIG_NAME} (native Copilot hooks)")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def main() -> None:
|
|
595
|
+
args = sys.argv[1:]
|
|
596
|
+
user_install = "--global" in args or "--user" in args
|
|
597
|
+
positional = [arg for arg in args if not arg.startswith("--")]
|
|
598
|
+
target = Path(positional[0]) if positional else Path.cwd()
|
|
599
|
+
if user_install:
|
|
600
|
+
generate(target, config_root=copilot_home(target))
|
|
601
|
+
else:
|
|
602
|
+
generate(target)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
if __name__ == "__main__":
|
|
606
|
+
main()
|