@softspark/ai-toolkit 4.15.0 → 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.
@@ -18,6 +18,9 @@ import tempfile
18
18
  from pathlib import Path
19
19
  from typing import Any
20
20
 
21
+ import secure_fs
22
+ from secure_fs import SecureDestination, run_secure_transaction
23
+
21
24
 
22
25
  OWNER_KEY = "AI_TOOLKIT_HOOK_OWNER"
23
26
  OWNER_VALUE = "ai-toolkit"
@@ -433,13 +436,11 @@ def _validate_entry(event: str, entry: Any) -> None:
433
436
  raise ValueError(f"Copilot {event} {key} must be positive")
434
437
 
435
438
 
436
- def _is_managed_config(path: Path) -> bool:
437
- if path.is_symlink() or not path.is_file():
438
- return False
439
+ def _is_managed_config_content(content: bytes) -> bool:
439
440
  try:
440
- data = json.loads(path.read_text(encoding="utf-8"))
441
+ data = json.loads(content.decode("utf-8"))
441
442
  _validate_document(data)
442
- except (OSError, json.JSONDecodeError, ValueError):
443
+ except (UnicodeError, json.JSONDecodeError, ValueError):
443
444
  return False
444
445
  entries = [entry for values in data["hooks"].values() for entry in values]
445
446
  return bool(entries) and all(
@@ -448,12 +449,28 @@ def _is_managed_config(path: Path) -> bool:
448
449
  )
449
450
 
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
+
451
468
  def _is_managed_script(path: Path) -> bool:
452
469
  if path.is_symlink() or not path.is_file():
453
470
  return False
454
471
  try:
455
- return SCRIPT_MARKER in path.read_text(encoding="utf-8")[:256]
456
- except (OSError, UnicodeError):
472
+ return _is_managed_script_content(path.read_bytes())
473
+ except OSError:
457
474
  return False
458
475
 
459
476
 
@@ -553,6 +570,106 @@ def copilot_home(home: Path | None = None) -> Path:
553
570
  return base / ".copilot"
554
571
 
555
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
+
556
673
  def generate(target_dir: Path, *, config_root: Path | None = None) -> None:
557
674
  """Generate repository hooks, or user hooks when ``config_root`` is set."""
558
675
  target_dir = Path(target_dir).expanduser().absolute()
@@ -1267,6 +1267,16 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
1267
1267
  emit_pointer = not _claude_skills_discoverable(cwd)
1268
1268
 
1269
1269
  if "copilot" in eds:
1270
+ from generate_copilot import (
1271
+ generate as gen_copilot_dir,
1272
+ preflight_cleanup as preflight_copilot_cleanup,
1273
+ )
1274
+ if not add_copilot_dir:
1275
+ from generate_copilot_hooks import (
1276
+ preflight_cleanup as preflight_copilot_hook_cleanup,
1277
+ )
1278
+ preflight_copilot_cleanup(cwd)
1279
+ preflight_copilot_hook_cleanup(cwd)
1270
1280
  inject_with_rules(
1271
1281
  "generate-copilot.sh",
1272
1282
  cwd / ".github" / "copilot-instructions.md",
@@ -1275,16 +1285,19 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
1275
1285
  _install_copilot_agents_md(cwd, rules_dir)
1276
1286
  # Agents and skills are the minimal Copilot surface. Standard and above
1277
1287
  # add path instructions, prompts, and native lifecycle hooks.
1278
- from generate_copilot import generate as gen_copilot_dir
1279
1288
  gen_copilot_dir(
1280
1289
  cwd,
1281
1290
  language_modules=language_modules,
1282
1291
  rules_dir=rules_dir,
1283
1292
  emit_prompts=add_copilot_dir,
1284
1293
  emit_instructions=add_copilot_dir,
1294
+ cleanup_disabled=not add_copilot_dir,
1285
1295
  )
1286
1296
  if add_copilot_dir:
1287
1297
  _try_generator("generate_copilot_hooks", cwd)
1298
+ else:
1299
+ from generate_copilot_hooks import cleanup as cleanup_copilot_hooks
1300
+ cleanup_copilot_hooks(cwd)
1288
1301
 
1289
1302
  if "cursor" in eds:
1290
1303
  inject_with_rules(