@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.
@@ -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 MANAGED_MARKER in path.read_text(encoding="utf-8").splitlines()[:12]
511
- except (OSError, UnicodeError):
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 path.read_text(encoding="utf-8") == legacy_content
539
- except (OSError, UnicodeError):
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 desired or path.is_symlink() or not _is_managed(path):
754
+ if path.name in keep:
612
755
  continue
613
- path.unlink()
614
- print(f" Removed stale: {label}/{path.name}")
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
- if emit_instructions:
899
- instr_dir = _prepare_output_dir(instr_root, "instructions")
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
- instruction_files: dict[str, callable] = dict(_make_instruction_files())
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
- # Language-specific instructions (auto-applied by file glob)
904
- for filename, content_fn in build_language_rules(language_modules).items():
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