@softspark/ai-toolkit 4.14.1 → 4.15.1

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