@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +11 -10
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/CLAUDE.md.template +3 -0
  5. package/app/agents/fact-checker.md +1 -1
  6. package/app/hooks/_search-capability.sh +3 -2
  7. package/app/hooks/stop-search-check.sh +2 -1
  8. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  9. package/kb/procedures/maintenance-sop.md +26 -13
  10. package/kb/procedures/release-verification-sop.md +41 -36
  11. package/kb/reference/architecture-overview.md +23 -7
  12. package/kb/reference/codex-cli-compatibility.md +96 -36
  13. package/kb/reference/extension-api.md +52 -9
  14. package/kb/reference/global-install-model.md +53 -21
  15. package/kb/reference/hooks-catalog.md +44 -8
  16. package/kb/reference/mcp-editor-compatibility.md +27 -6
  17. package/kb/reference/mcp-templates.md +12 -6
  18. package/kb/reference/opencode-compatibility.md +13 -7
  19. package/kb/reference/plugin-pack-conventions.md +7 -7
  20. package/kb/reference/skills-catalog.md +3 -3
  21. package/kb/reference/supported-tools-registry.md +19 -17
  22. package/kb/reference/windows-support.md +26 -3
  23. package/llms-full.txt +443 -180
  24. package/llms.txt +1 -1
  25. package/manifest.json +1 -1
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +448 -198
  28. package/scripts/dir_rules_shared.py +2 -11
  29. package/scripts/ecosystem_tools.json +29 -8
  30. package/scripts/emission.py +5 -91
  31. package/scripts/generate_agents_md.py +4 -87
  32. package/scripts/generate_codex.py +5 -95
  33. package/scripts/generate_codex_agents.py +242 -0
  34. package/scripts/generate_codex_hooks.py +648 -55
  35. package/scripts/generate_codex_skills.py +15 -6
  36. package/scripts/generate_copilot.py +771 -74
  37. package/scripts/generate_copilot_hooks.py +606 -0
  38. package/scripts/generate_cursor_hooks.py +453 -121
  39. package/scripts/generate_opencode_commands.py +4 -6
  40. package/scripts/inject_hook_cli.py +770 -205
  41. package/scripts/injection.py +102 -23
  42. package/scripts/install_steps/ai_tools.py +123 -83
  43. package/scripts/instruction_core.py +95 -0
  44. package/scripts/mcp_editors.py +934 -80
  45. package/scripts/mcp_manager.py +46 -26
  46. package/scripts/plugin.py +291 -114
  47. package/scripts/secure_fs.py +538 -0
  48. 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()