@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.
- package/AGENTS.md +117 -0
- package/CHANGELOG.md +9 -0
- package/README.md +7 -9
- package/app/.claude-plugin/plugin.json +1 -1
- package/kb/reference/global-install-model.md +5 -2
- package/kb/reference/windows-support.md +4 -3
- package/llms-full.txt +9 -5
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/copilot_legacy_hashes.json +338 -0
- package/scripts/generate_copilot.py +464 -71
- package/scripts/generate_copilot_hooks.py +124 -7
- package/scripts/install_steps/ai_tools.py +14 -1
|
@@ -44,6 +44,7 @@ Usage:
|
|
|
44
44
|
"""
|
|
45
45
|
from __future__ import annotations
|
|
46
46
|
|
|
47
|
+
import hashlib
|
|
47
48
|
import json
|
|
48
49
|
import os
|
|
49
50
|
import re
|
|
@@ -70,6 +71,8 @@ from emission import (
|
|
|
70
71
|
)
|
|
71
72
|
from frontmatter import frontmatter_field
|
|
72
73
|
from generator_base import render_generator
|
|
74
|
+
import secure_fs
|
|
75
|
+
from secure_fs import SecureDestination, run_secure_transaction
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
MANAGED_MARKER = "<!-- ai-toolkit-managed: github-copilot -->"
|
|
@@ -89,6 +92,38 @@ _FORBIDDEN_COPILOT_BODY_RE = re.compile(
|
|
|
89
92
|
r"\bview_skill\s*\(|\b(?:subagent_type|agent_type)\s*="
|
|
90
93
|
)
|
|
91
94
|
|
|
95
|
+
|
|
96
|
+
def _load_legacy_managed_hashes() -> dict[str, frozenset[str]]:
|
|
97
|
+
"""Load exact pre-marker output hashes shipped by releases v3.0-v4.14."""
|
|
98
|
+
manifest = Path(__file__).with_name("copilot_legacy_hashes.json")
|
|
99
|
+
value = json.loads(manifest.read_text(encoding="utf-8"))
|
|
100
|
+
if not isinstance(value, dict):
|
|
101
|
+
raise ValueError(f"Invalid Copilot legacy hash manifest: {manifest}")
|
|
102
|
+
result: dict[str, frozenset[str]] = {}
|
|
103
|
+
for name, hashes in value.items():
|
|
104
|
+
valid_name = (
|
|
105
|
+
isinstance(name, str)
|
|
106
|
+
and Path(name).name == name
|
|
107
|
+
and name.startswith(PREFIX)
|
|
108
|
+
and name.endswith((".instructions.md", ".prompt.md"))
|
|
109
|
+
)
|
|
110
|
+
valid_hashes = (
|
|
111
|
+
isinstance(hashes, list)
|
|
112
|
+
and bool(hashes)
|
|
113
|
+
and all(
|
|
114
|
+
isinstance(digest, str)
|
|
115
|
+
and re.fullmatch(r"[0-9a-f]{64}", digest)
|
|
116
|
+
for digest in hashes
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
if not valid_name or not valid_hashes:
|
|
120
|
+
raise ValueError(f"Invalid Copilot legacy hash entry: {name!r}")
|
|
121
|
+
result[name] = frozenset(hashes)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_LEGACY_MANAGED_SHA256 = _load_legacy_managed_hashes()
|
|
126
|
+
|
|
92
127
|
# ---------------------------------------------------------------------------
|
|
93
128
|
# Shared configuration for the legacy stdout output
|
|
94
129
|
# ---------------------------------------------------------------------------
|
|
@@ -490,6 +525,28 @@ def _user_agent_names(directory: Path) -> set[str]:
|
|
|
490
525
|
return names
|
|
491
526
|
|
|
492
527
|
|
|
528
|
+
def _desired_agent_files(
|
|
529
|
+
directory: Path,
|
|
530
|
+
) -> dict[str, tuple[str, str | None]]:
|
|
531
|
+
"""Render managed agents while honoring user-owned logical names."""
|
|
532
|
+
user_names = _user_agent_names(directory)
|
|
533
|
+
desired: dict[str, tuple[str, str | None]] = {}
|
|
534
|
+
source_names: set[str] = set()
|
|
535
|
+
for agent_file in sorted(agents_dir.glob("*.md")):
|
|
536
|
+
name, _, content = _render_agent(agent_file)
|
|
537
|
+
if name in source_names:
|
|
538
|
+
raise ValueError(f"Duplicate Copilot agent name: {name}")
|
|
539
|
+
source_names.add(name)
|
|
540
|
+
if name in user_names:
|
|
541
|
+
_warn_preserved(
|
|
542
|
+
directory / f"{PREFIX}{name}.agent.md",
|
|
543
|
+
f"logical name '{name}' belongs to a user agent",
|
|
544
|
+
)
|
|
545
|
+
continue
|
|
546
|
+
desired[f"{PREFIX}{name}.agent.md"] = (content, None)
|
|
547
|
+
return desired
|
|
548
|
+
|
|
549
|
+
|
|
493
550
|
def _prepare_output_dir(base: Path, child_name: str) -> Path:
|
|
494
551
|
"""Create a customization directory without following managed-root symlinks."""
|
|
495
552
|
output_dir = base / child_name
|
|
@@ -503,12 +560,20 @@ def _prepare_output_dir(base: Path, child_name: str) -> Path:
|
|
|
503
560
|
return output_dir
|
|
504
561
|
|
|
505
562
|
|
|
563
|
+
def _is_managed_content(content: bytes) -> bool:
|
|
564
|
+
try:
|
|
565
|
+
lines = content.decode("utf-8").splitlines()[:12]
|
|
566
|
+
except UnicodeError:
|
|
567
|
+
return False
|
|
568
|
+
return MANAGED_MARKER in lines
|
|
569
|
+
|
|
570
|
+
|
|
506
571
|
def _is_managed(path: Path) -> bool:
|
|
507
572
|
if path.is_symlink() or not path.is_file():
|
|
508
573
|
return False
|
|
509
574
|
try:
|
|
510
|
-
return
|
|
511
|
-
except
|
|
575
|
+
return _is_managed_content(path.read_bytes())
|
|
576
|
+
except OSError:
|
|
512
577
|
return False
|
|
513
578
|
|
|
514
579
|
|
|
@@ -527,6 +592,18 @@ def _legacy_instructions_content(content: str) -> str:
|
|
|
527
592
|
return "\n".join(lines) + "\n"
|
|
528
593
|
|
|
529
594
|
|
|
595
|
+
def _matches_legacy_managed(
|
|
596
|
+
filename: str,
|
|
597
|
+
content: bytes,
|
|
598
|
+
legacy_content: str | None,
|
|
599
|
+
) -> bool:
|
|
600
|
+
"""Recognize exact pre-marker output without trusting the filename alone."""
|
|
601
|
+
if legacy_content is not None and content == legacy_content.encode("utf-8"):
|
|
602
|
+
return True
|
|
603
|
+
expected = _LEGACY_MANAGED_SHA256.get(filename, frozenset())
|
|
604
|
+
return hashlib.sha256(content).hexdigest() in expected
|
|
605
|
+
|
|
606
|
+
|
|
530
607
|
def _may_replace(path: Path, legacy_content: str | None) -> bool:
|
|
531
608
|
if path.is_symlink():
|
|
532
609
|
return False
|
|
@@ -535,8 +612,12 @@ def _may_replace(path: Path, legacy_content: str | None) -> bool:
|
|
|
535
612
|
if legacy_content is None or not path.is_file():
|
|
536
613
|
return False
|
|
537
614
|
try:
|
|
538
|
-
return
|
|
539
|
-
|
|
615
|
+
return _matches_legacy_managed(
|
|
616
|
+
path.name,
|
|
617
|
+
path.read_bytes(),
|
|
618
|
+
legacy_content,
|
|
619
|
+
)
|
|
620
|
+
except OSError:
|
|
540
621
|
return False
|
|
541
622
|
|
|
542
623
|
|
|
@@ -584,8 +665,27 @@ def _sync_managed_files(
|
|
|
584
665
|
*,
|
|
585
666
|
suffix: str,
|
|
586
667
|
label: str,
|
|
668
|
+
trusted_root: Path,
|
|
587
669
|
) -> None:
|
|
588
670
|
"""Write desired files first, then remove only stale managed files."""
|
|
671
|
+
stale_candidates = _managed_cleanup_candidates(
|
|
672
|
+
directory,
|
|
673
|
+
set(desired),
|
|
674
|
+
{},
|
|
675
|
+
suffix=suffix,
|
|
676
|
+
label=label,
|
|
677
|
+
trusted_root=trusted_root,
|
|
678
|
+
)
|
|
679
|
+
if stale_candidates:
|
|
680
|
+
_sync_managed_files_with_cleanup(
|
|
681
|
+
directory,
|
|
682
|
+
desired,
|
|
683
|
+
stale_candidates,
|
|
684
|
+
label=label,
|
|
685
|
+
trusted_root=trusted_root,
|
|
686
|
+
)
|
|
687
|
+
return
|
|
688
|
+
|
|
589
689
|
staged: list[tuple[Path, Path, str | None, str]] = []
|
|
590
690
|
try:
|
|
591
691
|
for name, (content, legacy_content) in sorted(desired.items()):
|
|
@@ -607,11 +707,189 @@ def _sync_managed_files(
|
|
|
607
707
|
for temp_path, _, _, _ in staged:
|
|
608
708
|
temp_path.unlink(missing_ok=True)
|
|
609
709
|
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _require_secure_cleanup() -> None:
|
|
713
|
+
if secure_fs.SECURE_DIR_FD:
|
|
714
|
+
return
|
|
715
|
+
raise RuntimeError(
|
|
716
|
+
"Copilot managed cleanup requires POSIX dir_fd and O_NOFOLLOW; "
|
|
717
|
+
"No files were changed"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _cleanup_is_required(
|
|
722
|
+
directory: Path,
|
|
723
|
+
keep: set[str],
|
|
724
|
+
*,
|
|
725
|
+
suffix: str,
|
|
726
|
+
) -> bool:
|
|
727
|
+
"""Detect cleanup work before any Copilot surface is mutated."""
|
|
728
|
+
if not directory.exists():
|
|
729
|
+
return False
|
|
730
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
731
|
+
raise RuntimeError(f"Refusing unsafe Copilot output directory: {directory}")
|
|
732
|
+
return any(
|
|
733
|
+
path.name not in keep and not path.is_symlink() and path.is_file()
|
|
734
|
+
for path in directory.glob(f"{PREFIX}*{suffix}")
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _managed_cleanup_candidates(
|
|
739
|
+
directory: Path,
|
|
740
|
+
keep: set[str],
|
|
741
|
+
legacy_files: dict[str, str],
|
|
742
|
+
*,
|
|
743
|
+
suffix: str,
|
|
744
|
+
label: str,
|
|
745
|
+
trusted_root: Path,
|
|
746
|
+
) -> list[tuple[SecureDestination, str | None]]:
|
|
747
|
+
"""Pin regular stale candidates without following user symlinks."""
|
|
748
|
+
if not directory.exists():
|
|
749
|
+
return []
|
|
750
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
751
|
+
raise RuntimeError(f"Refusing unsafe Copilot output directory: {directory}")
|
|
752
|
+
candidates: list[tuple[SecureDestination, str | None]] = []
|
|
610
753
|
for path in sorted(directory.glob(f"{PREFIX}*{suffix}")):
|
|
611
|
-
if path.name in
|
|
754
|
+
if path.name in keep:
|
|
612
755
|
continue
|
|
613
|
-
path.
|
|
614
|
-
|
|
756
|
+
if path.is_symlink() or not path.is_file():
|
|
757
|
+
_warn_preserved(path, "stale file is not provably managed")
|
|
758
|
+
continue
|
|
759
|
+
candidates.append((
|
|
760
|
+
SecureDestination(
|
|
761
|
+
path=path,
|
|
762
|
+
trusted_root=trusted_root,
|
|
763
|
+
label=f"Copilot {label}/{path.name}",
|
|
764
|
+
),
|
|
765
|
+
legacy_files.get(path.name),
|
|
766
|
+
))
|
|
767
|
+
return candidates
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _sync_managed_files_with_cleanup(
|
|
771
|
+
directory: Path,
|
|
772
|
+
desired: dict[str, tuple[str, str | None]],
|
|
773
|
+
stale_candidates: list[tuple[SecureDestination, str | None]],
|
|
774
|
+
*,
|
|
775
|
+
label: str,
|
|
776
|
+
trusted_root: Path,
|
|
777
|
+
) -> None:
|
|
778
|
+
"""Atomically update desired files and remove stale managed output."""
|
|
779
|
+
_require_secure_cleanup()
|
|
780
|
+
write_candidates: list[
|
|
781
|
+
tuple[SecureDestination, bytes, str | None]
|
|
782
|
+
] = []
|
|
783
|
+
for name, (content, legacy_content) in sorted(desired.items()):
|
|
784
|
+
path = directory / name
|
|
785
|
+
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
786
|
+
_warn_preserved(path, "destination is user-owned or a symlink")
|
|
787
|
+
continue
|
|
788
|
+
write_candidates.append((
|
|
789
|
+
SecureDestination(
|
|
790
|
+
path=path,
|
|
791
|
+
trusted_root=trusted_root,
|
|
792
|
+
label=f"Copilot {label}/{name}",
|
|
793
|
+
),
|
|
794
|
+
content.encode("utf-8"),
|
|
795
|
+
legacy_content,
|
|
796
|
+
))
|
|
797
|
+
|
|
798
|
+
destinations = [item[0] for item in write_candidates]
|
|
799
|
+
destinations.extend(item[0] for item in stale_candidates)
|
|
800
|
+
messages: list[str] = []
|
|
801
|
+
|
|
802
|
+
def update_and_remove(transaction) -> None:
|
|
803
|
+
for destination, content, legacy_content in write_candidates:
|
|
804
|
+
initial = transaction.initial_content(destination)
|
|
805
|
+
if initial is not None and not (
|
|
806
|
+
_is_managed_content(initial)
|
|
807
|
+
or _matches_legacy_managed(
|
|
808
|
+
destination.path.name,
|
|
809
|
+
initial,
|
|
810
|
+
legacy_content,
|
|
811
|
+
)
|
|
812
|
+
):
|
|
813
|
+
_warn_preserved(
|
|
814
|
+
destination.path,
|
|
815
|
+
"destination is user-owned or a symlink",
|
|
816
|
+
)
|
|
817
|
+
continue
|
|
818
|
+
transaction.atomic_write(destination, content)
|
|
819
|
+
messages.append(f" Generated: {label}/{destination.path.name}")
|
|
820
|
+
|
|
821
|
+
for destination, legacy_content in stale_candidates:
|
|
822
|
+
initial = transaction.initial_content(destination)
|
|
823
|
+
if initial is None:
|
|
824
|
+
continue
|
|
825
|
+
if not (
|
|
826
|
+
_is_managed_content(initial)
|
|
827
|
+
or _matches_legacy_managed(
|
|
828
|
+
destination.path.name,
|
|
829
|
+
initial,
|
|
830
|
+
legacy_content,
|
|
831
|
+
)
|
|
832
|
+
):
|
|
833
|
+
_warn_preserved(
|
|
834
|
+
destination.path,
|
|
835
|
+
"stale file is not provably managed",
|
|
836
|
+
)
|
|
837
|
+
continue
|
|
838
|
+
transaction.unlink(destination)
|
|
839
|
+
messages.append(f" Removed stale: {label}/{destination.path.name}")
|
|
840
|
+
|
|
841
|
+
run_secure_transaction(destinations, update_and_remove)
|
|
842
|
+
for message in messages:
|
|
843
|
+
print(message)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _cleanup_managed_files(
|
|
847
|
+
directory: Path,
|
|
848
|
+
keep: set[str],
|
|
849
|
+
legacy_files: dict[str, str],
|
|
850
|
+
*,
|
|
851
|
+
suffix: str,
|
|
852
|
+
label: str,
|
|
853
|
+
trusted_root: Path,
|
|
854
|
+
) -> None:
|
|
855
|
+
"""Remove stale managed or exact legacy files, preserving user content."""
|
|
856
|
+
candidates = _managed_cleanup_candidates(
|
|
857
|
+
directory,
|
|
858
|
+
keep,
|
|
859
|
+
legacy_files,
|
|
860
|
+
suffix=suffix,
|
|
861
|
+
label=label,
|
|
862
|
+
trusted_root=trusted_root,
|
|
863
|
+
)
|
|
864
|
+
if not candidates:
|
|
865
|
+
return
|
|
866
|
+
_require_secure_cleanup()
|
|
867
|
+
|
|
868
|
+
def remove_stale(transaction) -> None:
|
|
869
|
+
for destination, legacy_content in candidates:
|
|
870
|
+
content = transaction.initial_content(destination)
|
|
871
|
+
if content is None:
|
|
872
|
+
continue
|
|
873
|
+
if not (
|
|
874
|
+
_is_managed_content(content)
|
|
875
|
+
or _matches_legacy_managed(
|
|
876
|
+
destination.path.name,
|
|
877
|
+
content,
|
|
878
|
+
legacy_content,
|
|
879
|
+
)
|
|
880
|
+
):
|
|
881
|
+
_warn_preserved(
|
|
882
|
+
destination.path,
|
|
883
|
+
"stale file is not provably managed",
|
|
884
|
+
)
|
|
885
|
+
continue
|
|
886
|
+
transaction.unlink(destination)
|
|
887
|
+
print(f" Removed stale: {label}/{destination.path.name}")
|
|
888
|
+
|
|
889
|
+
run_secure_transaction(
|
|
890
|
+
[destination for destination, _ in candidates],
|
|
891
|
+
remove_stale,
|
|
892
|
+
)
|
|
615
893
|
|
|
616
894
|
|
|
617
895
|
# ---------------------------------------------------------------------------
|
|
@@ -870,6 +1148,109 @@ def _sync_copilot_skills(customization_root: Path, *, label: str) -> None:
|
|
|
870
1148
|
# ---------------------------------------------------------------------------
|
|
871
1149
|
|
|
872
1150
|
|
|
1151
|
+
def _desired_instruction_files(
|
|
1152
|
+
language_modules: list[str] | None,
|
|
1153
|
+
rules_dir: Path | None,
|
|
1154
|
+
) -> dict[str, tuple[str, str]]:
|
|
1155
|
+
instruction_files: dict[str, callable] = dict(_make_instruction_files())
|
|
1156
|
+
for filename, content_fn in build_language_rules(language_modules).items():
|
|
1157
|
+
language = filename.removeprefix(f"{PREFIX}lang-").removesuffix(".md")
|
|
1158
|
+
apply_to = ",".join(LANG_GLOBS.get(language, ())) or "**"
|
|
1159
|
+
new_name = f"{PREFIX}lang-{language}.instructions.md"
|
|
1160
|
+
instruction_files[new_name] = (
|
|
1161
|
+
lambda fn, name, pattern: lambda: _instructions_file(
|
|
1162
|
+
fn(),
|
|
1163
|
+
apply_to=pattern,
|
|
1164
|
+
description=f"{name.title()} language rules",
|
|
1165
|
+
)
|
|
1166
|
+
)(content_fn, language, apply_to)
|
|
1167
|
+
for filename, content_fn in build_registered_rules(rules_dir).items():
|
|
1168
|
+
stem = filename.removeprefix(f"{PREFIX}custom-").removesuffix(".md")
|
|
1169
|
+
new_name = f"{PREFIX}custom-{stem}.instructions.md"
|
|
1170
|
+
instruction_files[new_name] = (
|
|
1171
|
+
lambda fn, name: lambda: _instructions_file(
|
|
1172
|
+
fn(),
|
|
1173
|
+
apply_to="**",
|
|
1174
|
+
description=f"Custom rule: {name}",
|
|
1175
|
+
)
|
|
1176
|
+
)(content_fn, stem)
|
|
1177
|
+
desired: dict[str, tuple[str, str]] = {}
|
|
1178
|
+
for name, content_fn in instruction_files.items():
|
|
1179
|
+
content = content_fn()
|
|
1180
|
+
desired[name] = (content, _legacy_instructions_content(content))
|
|
1181
|
+
return desired
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _desired_prompt_files() -> dict[str, tuple[str, str]]:
|
|
1185
|
+
desired: dict[str, tuple[str, str]] = {}
|
|
1186
|
+
for name, description, body, legacy_body in _user_invocable_skills():
|
|
1187
|
+
if not _SAFE_NAME_RE.fullmatch(name):
|
|
1188
|
+
raise ValueError(f"Invalid Copilot prompt name: {name}")
|
|
1189
|
+
portable_body = _portable_copilot_body(body, include_execution_note=True)
|
|
1190
|
+
desired[f"{PREFIX}{name}.prompt.md"] = (
|
|
1191
|
+
_prompt_file(description, portable_body),
|
|
1192
|
+
_legacy_prompt_file(description, legacy_body),
|
|
1193
|
+
)
|
|
1194
|
+
return desired
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def preflight_cleanup(
|
|
1198
|
+
target_dir: Path,
|
|
1199
|
+
*,
|
|
1200
|
+
config_root: Path | None = None,
|
|
1201
|
+
) -> None:
|
|
1202
|
+
"""Validate profile-cleanup paths before an installer mutates any surface."""
|
|
1203
|
+
target_dir = Path(target_dir).expanduser().absolute()
|
|
1204
|
+
github_dir = target_dir / ".github"
|
|
1205
|
+
customization_root = (
|
|
1206
|
+
github_dir
|
|
1207
|
+
if config_root is None
|
|
1208
|
+
else Path(config_root).expanduser().absolute()
|
|
1209
|
+
)
|
|
1210
|
+
trusted_root = target_dir if config_root is None else customization_root
|
|
1211
|
+
desired_agents = _desired_agent_files(customization_root / "agents")
|
|
1212
|
+
candidates = _managed_cleanup_candidates(
|
|
1213
|
+
customization_root / "agents",
|
|
1214
|
+
set(desired_agents),
|
|
1215
|
+
{},
|
|
1216
|
+
suffix=".agent.md",
|
|
1217
|
+
label=(
|
|
1218
|
+
".github/agents"
|
|
1219
|
+
if config_root is None
|
|
1220
|
+
else "$COPILOT_HOME/agents"
|
|
1221
|
+
),
|
|
1222
|
+
trusted_root=trusted_root,
|
|
1223
|
+
)
|
|
1224
|
+
candidates.extend(_managed_cleanup_candidates(
|
|
1225
|
+
customization_root / "instructions",
|
|
1226
|
+
set(),
|
|
1227
|
+
{},
|
|
1228
|
+
suffix=".instructions.md",
|
|
1229
|
+
label=(
|
|
1230
|
+
".github/instructions"
|
|
1231
|
+
if config_root is None
|
|
1232
|
+
else "$COPILOT_HOME/instructions"
|
|
1233
|
+
),
|
|
1234
|
+
trusted_root=trusted_root,
|
|
1235
|
+
))
|
|
1236
|
+
if config_root is None:
|
|
1237
|
+
candidates.extend(_managed_cleanup_candidates(
|
|
1238
|
+
github_dir / "prompts",
|
|
1239
|
+
set(),
|
|
1240
|
+
{},
|
|
1241
|
+
suffix=".prompt.md",
|
|
1242
|
+
label=".github/prompts",
|
|
1243
|
+
trusted_root=target_dir,
|
|
1244
|
+
))
|
|
1245
|
+
if not candidates:
|
|
1246
|
+
return
|
|
1247
|
+
_require_secure_cleanup()
|
|
1248
|
+
run_secure_transaction(
|
|
1249
|
+
[destination for destination, _ in candidates],
|
|
1250
|
+
lambda _transaction: None,
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
|
|
873
1254
|
def generate(target_dir: Path, *,
|
|
874
1255
|
language_modules: list[str] | None = None,
|
|
875
1256
|
rules_dir: Path | None = None,
|
|
@@ -877,6 +1258,7 @@ def generate(target_dir: Path, *,
|
|
|
877
1258
|
emit_prompts: bool = True,
|
|
878
1259
|
emit_instructions: bool = True,
|
|
879
1260
|
emit_skills: bool = True,
|
|
1261
|
+
cleanup_disabled: bool = False,
|
|
880
1262
|
config_root: Path | None = None) -> None:
|
|
881
1263
|
"""Write Copilot instructions, custom agents, skills, and prompt files.
|
|
882
1264
|
|
|
@@ -887,78 +1269,95 @@ def generate(target_dir: Path, *,
|
|
|
887
1269
|
``.github/copilot-instructions.md`` is intentionally not written here —
|
|
888
1270
|
the legacy ``main()`` entry point still emits it to stdout so existing
|
|
889
1271
|
scripts (including ``ai-toolkit install``) keep working unchanged.
|
|
1272
|
+
|
|
1273
|
+
``cleanup_disabled=True`` is the installer-only profile-transition mode:
|
|
1274
|
+
disabled instruction and prompt surfaces are removed only when their
|
|
1275
|
+
ownership marker or exact historical output hash proves toolkit ownership.
|
|
890
1276
|
"""
|
|
891
1277
|
github_dir = target_dir / ".github"
|
|
892
1278
|
customization_root = config_root if config_root is not None else github_dir
|
|
1279
|
+
customization_trusted_root = (
|
|
1280
|
+
target_dir if config_root is None else customization_root.parent
|
|
1281
|
+
)
|
|
893
1282
|
instr_root = customization_root
|
|
894
1283
|
instr_label = "$COPILOT_HOME/instructions" if config_root is not None else ".github/instructions"
|
|
895
1284
|
agent_label = "$COPILOT_HOME/agents" if config_root is not None else ".github/agents"
|
|
896
1285
|
skill_label = "$COPILOT_HOME/skills" if config_root is not None else ".github/skills"
|
|
897
1286
|
|
|
898
|
-
|
|
899
|
-
|
|
1287
|
+
desired_instructions = (
|
|
1288
|
+
_desired_instruction_files(language_modules, rules_dir)
|
|
1289
|
+
if emit_instructions or cleanup_disabled
|
|
1290
|
+
else {}
|
|
1291
|
+
)
|
|
1292
|
+
agent_dir_path = customization_root / "agents"
|
|
1293
|
+
if emit_agents and (
|
|
1294
|
+
customization_root.is_symlink() or agent_dir_path.is_symlink()
|
|
1295
|
+
):
|
|
1296
|
+
raise RuntimeError(
|
|
1297
|
+
f"Refusing symlinked Copilot output directory: {agent_dir_path}"
|
|
1298
|
+
)
|
|
1299
|
+
desired_agents = (
|
|
1300
|
+
_desired_agent_files(agent_dir_path) if emit_agents else {}
|
|
1301
|
+
)
|
|
1302
|
+
needs_project_prompt_state = (
|
|
1303
|
+
emit_prompts or (cleanup_disabled and config_root is None)
|
|
1304
|
+
)
|
|
1305
|
+
desired_prompts = (
|
|
1306
|
+
_desired_prompt_files() if needs_project_prompt_state else {}
|
|
1307
|
+
)
|
|
900
1308
|
|
|
901
|
-
|
|
1309
|
+
cleanup_checks: list[tuple[Path, set[str], str]] = []
|
|
1310
|
+
if emit_instructions or cleanup_disabled:
|
|
1311
|
+
cleanup_checks.append((
|
|
1312
|
+
instr_root / "instructions",
|
|
1313
|
+
set(desired_instructions) if emit_instructions else set(),
|
|
1314
|
+
".instructions.md",
|
|
1315
|
+
))
|
|
1316
|
+
if emit_agents:
|
|
1317
|
+
cleanup_checks.append((
|
|
1318
|
+
agent_dir_path,
|
|
1319
|
+
set(desired_agents),
|
|
1320
|
+
".agent.md",
|
|
1321
|
+
))
|
|
1322
|
+
if needs_project_prompt_state:
|
|
1323
|
+
cleanup_checks.append((
|
|
1324
|
+
github_dir / "prompts",
|
|
1325
|
+
set(desired_prompts) if emit_prompts else set(),
|
|
1326
|
+
".prompt.md",
|
|
1327
|
+
))
|
|
1328
|
+
if any(
|
|
1329
|
+
_cleanup_is_required(directory, keep, suffix=suffix)
|
|
1330
|
+
for directory, keep, suffix in cleanup_checks
|
|
1331
|
+
):
|
|
1332
|
+
_require_secure_cleanup()
|
|
902
1333
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
lang = filename.removeprefix(f"{PREFIX}lang-").removesuffix(".md")
|
|
906
|
-
globs = LANG_GLOBS.get(lang)
|
|
907
|
-
apply_to = ",".join(globs) if globs else "**"
|
|
908
|
-
new_name = f"{PREFIX}lang-{lang}.instructions.md"
|
|
909
|
-
instruction_files[new_name] = (lambda fn, language_name, a: lambda: _instructions_file(
|
|
910
|
-
fn(),
|
|
911
|
-
apply_to=a,
|
|
912
|
-
description=f"{language_name.title()} language rules",
|
|
913
|
-
))(content_fn, lang, apply_to)
|
|
914
|
-
|
|
915
|
-
# User-registered custom rules (always-on)
|
|
916
|
-
for filename, content_fn in build_registered_rules(rules_dir).items():
|
|
917
|
-
stem = filename.removeprefix(f"{PREFIX}custom-").removesuffix(".md")
|
|
918
|
-
new_name = f"{PREFIX}custom-{stem}.instructions.md"
|
|
919
|
-
instruction_files[new_name] = (lambda fn, n: lambda: _instructions_file(
|
|
920
|
-
fn(),
|
|
921
|
-
apply_to="**",
|
|
922
|
-
description=f"Custom rule: {n}",
|
|
923
|
-
))(content_fn, stem)
|
|
924
|
-
|
|
925
|
-
desired_instructions: dict[str, tuple[str, str | None]] = {}
|
|
926
|
-
for name, content_fn in instruction_files.items():
|
|
927
|
-
content = content_fn()
|
|
928
|
-
desired_instructions[name] = (
|
|
929
|
-
content,
|
|
930
|
-
_legacy_instructions_content(content),
|
|
931
|
-
)
|
|
1334
|
+
if emit_instructions:
|
|
1335
|
+
instr_dir = _prepare_output_dir(instr_root, "instructions")
|
|
932
1336
|
_sync_managed_files(
|
|
933
1337
|
instr_dir,
|
|
934
1338
|
desired_instructions,
|
|
935
1339
|
suffix=".instructions.md",
|
|
936
1340
|
label=instr_label,
|
|
1341
|
+
trusted_root=customization_trusted_root,
|
|
1342
|
+
)
|
|
1343
|
+
elif cleanup_disabled:
|
|
1344
|
+
_cleanup_managed_files(
|
|
1345
|
+
instr_root / "instructions",
|
|
1346
|
+
set(),
|
|
1347
|
+
{name: legacy for name, (_, legacy) in desired_instructions.items()},
|
|
1348
|
+
suffix=".instructions.md",
|
|
1349
|
+
label=instr_label,
|
|
1350
|
+
trusted_root=customization_trusted_root,
|
|
937
1351
|
)
|
|
938
1352
|
|
|
939
1353
|
if emit_agents:
|
|
940
1354
|
agent_dir = _prepare_output_dir(customization_root, "agents")
|
|
941
|
-
user_names = _user_agent_names(agent_dir)
|
|
942
|
-
desired_agents: dict[str, tuple[str, str | None]] = {}
|
|
943
|
-
source_names: set[str] = set()
|
|
944
|
-
for agent_file in sorted(agents_dir.glob("*.md")):
|
|
945
|
-
name, _, content = _render_agent(agent_file)
|
|
946
|
-
if name in source_names:
|
|
947
|
-
raise ValueError(f"Duplicate Copilot agent name: {name}")
|
|
948
|
-
source_names.add(name)
|
|
949
|
-
if name in user_names:
|
|
950
|
-
_warn_preserved(
|
|
951
|
-
agent_dir / f"{PREFIX}{name}.agent.md",
|
|
952
|
-
f"logical name '{name}' belongs to a user agent",
|
|
953
|
-
)
|
|
954
|
-
continue
|
|
955
|
-
filename = f"{PREFIX}{name}.agent.md"
|
|
956
|
-
desired_agents[filename] = (content, None)
|
|
957
1355
|
_sync_managed_files(
|
|
958
1356
|
agent_dir,
|
|
959
1357
|
desired_agents,
|
|
960
1358
|
suffix=".agent.md",
|
|
961
1359
|
label=agent_label,
|
|
1360
|
+
trusted_root=customization_trusted_root,
|
|
962
1361
|
)
|
|
963
1362
|
|
|
964
1363
|
if emit_skills:
|
|
@@ -966,27 +1365,21 @@ def generate(target_dir: Path, *,
|
|
|
966
1365
|
|
|
967
1366
|
if emit_prompts:
|
|
968
1367
|
prompt_dir = _prepare_output_dir(github_dir, "prompts")
|
|
969
|
-
|
|
970
|
-
skills = _user_invocable_skills()
|
|
971
|
-
desired_prompts: dict[str, tuple[str, str | None]] = {}
|
|
972
|
-
for name, description, body, legacy_body in skills:
|
|
973
|
-
if not _SAFE_NAME_RE.fullmatch(name):
|
|
974
|
-
raise ValueError(f"Invalid Copilot prompt name: {name}")
|
|
975
|
-
filename = f"{PREFIX}{name}.prompt.md"
|
|
976
|
-
portable_body = _portable_copilot_body(
|
|
977
|
-
body,
|
|
978
|
-
include_execution_note=True,
|
|
979
|
-
)
|
|
980
|
-
content = _prompt_file(description, portable_body)
|
|
981
|
-
desired_prompts[filename] = (
|
|
982
|
-
content,
|
|
983
|
-
_legacy_prompt_file(description, legacy_body),
|
|
984
|
-
)
|
|
985
1368
|
_sync_managed_files(
|
|
986
1369
|
prompt_dir,
|
|
987
1370
|
desired_prompts,
|
|
988
1371
|
suffix=".prompt.md",
|
|
989
1372
|
label=".github/prompts",
|
|
1373
|
+
trusted_root=target_dir,
|
|
1374
|
+
)
|
|
1375
|
+
elif cleanup_disabled and config_root is None:
|
|
1376
|
+
_cleanup_managed_files(
|
|
1377
|
+
github_dir / "prompts",
|
|
1378
|
+
set(),
|
|
1379
|
+
{name: legacy for name, (_, legacy) in desired_prompts.items()},
|
|
1380
|
+
suffix=".prompt.md",
|
|
1381
|
+
label=".github/prompts",
|
|
1382
|
+
trusted_root=target_dir,
|
|
990
1383
|
)
|
|
991
1384
|
|
|
992
1385
|
|