@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
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  """Generate GitHub Copilot customization files.
3
3
 
4
- This generator produces three surfaces, all on the OSS/Free/Pro tier
5
- (no Business/Enterprise gating, no server-side MCP config):
4
+ This generator produces five repository customization surfaces without
5
+ subscription-tier gating or server-side MCP configuration:
6
6
 
7
7
  1. ``.github/copilot-instructions.md`` — always-on repository instructions.
8
8
  Supported by GitHub.com Copilot Chat, Copilot cloud agent, and VS Code
@@ -17,28 +17,40 @@ This generator produces three surfaces, all on the OSS/Free/Pro tier
17
17
  Invoked manually in VS Code Copilot Chat via ``/name``. Written only
18
18
  when ``generate()`` is called with a target directory.
19
19
 
20
+ 4. ``.github/agents/*.agent.md`` — native Copilot custom agents generated
21
+ from ``app/agents``. User-level generation writes the same managed agents
22
+ to ``~/.copilot/agents``.
23
+
24
+ 5. ``.github/skills/*/SKILL.md`` — portable, materialized Copilot skills with
25
+ their referenced scripts and assets. User-level generation writes them to
26
+ the active Copilot configuration root's ``skills/`` directory.
27
+
20
28
  GitHub Copilot code review (generally available 2026-06-18, all tiers) now
21
29
  automatically reads the root-level ``AGENTS.md`` when generating review
22
30
  feedback. We already emit that file via ``scripts/generate_agents_md.py``, so
23
31
  no Copilot-specific emission is added here; ``AGENTS.md`` is tracked in this
24
32
  tool's ``capability_markers`` in ``scripts/ecosystem_tools.json``.
25
33
 
26
- Features that live on Pro/Pro+/Business/Enterprise tiers are intentionally
27
- not generated (classified as class C in the ecosystem-sync SOP):
28
- * ``.github/agents/*.agent.md`` custom agent profiles (tier-gated)
29
- * repo-level MCP configuration (GitHub repo Settings UI, tier-gated)
30
- * organization-wide and enterprise-wide instructions
34
+ Copilot hooks are emitted by ``generate_copilot_hooks.py`` because their
35
+ transactional JSON/runtime lifecycle is separate from Markdown customization.
36
+ Project MCP remains handled by the editor MCP sync path.
31
37
 
32
38
  Usage:
33
39
  # Legacy stdout mode (repo-wide instructions only)
34
40
  python3 scripts/generate_copilot.py > .github/copilot-instructions.md
35
41
 
36
- # Directory mode (repo-wide + path-specific + prompt files)
42
+ # Directory mode (path-specific instructions + agents + prompt files)
37
43
  python3 scripts/generate_copilot.py <target-dir>
38
44
  """
39
45
  from __future__ import annotations
40
46
 
47
+ import hashlib
48
+ import json
49
+ import os
50
+ import re
51
+ import shutil
41
52
  import sys
53
+ import tempfile
42
54
  from pathlib import Path
43
55
 
44
56
  sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -59,6 +71,58 @@ from emission import (
59
71
  )
60
72
  from frontmatter import frontmatter_field
61
73
  from generator_base import render_generator
74
+ import secure_fs
75
+ from secure_fs import SecureDestination, run_secure_transaction
76
+
77
+
78
+ MANAGED_MARKER = "<!-- ai-toolkit-managed: github-copilot -->"
79
+ SKILL_MANIFEST = ".ai-toolkit-managed-files"
80
+ MAX_AGENT_BODY_BYTES = 30_000
81
+ _AGENT_START_RE = re.compile(r"\bAgent\s*\(")
82
+ _TASK_CALL_RE = re.compile(
83
+ r"\bTask(?:Create|List|Update|Get|Output|Stop)\s*\([^)]*\)",
84
+ re.S,
85
+ )
86
+ _SAFE_NAME_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]*\Z")
87
+ _FORBIDDEN_COPILOT_BODY_RE = re.compile(
88
+ r"\$ARGUMENTS|CLAUDE_SKILL_DIR|CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS|"
89
+ r"\bAgent\s*\(|\bTask(?:Create|List|Update|Get|Output|Stop)\b|"
90
+ r"\b(?:TeamCreate|TeamDelete|SendMessage)\b|"
91
+ r"\b(?:spawn_agent|send_input|wait_agent|close_agent|update_plan|fork_context)\b|"
92
+ r"\bview_skill\s*\(|\b(?:subagent_type|agent_type)\s*="
93
+ )
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()
62
126
 
63
127
  # ---------------------------------------------------------------------------
64
128
  # Shared configuration for the legacy stdout output
@@ -78,7 +142,7 @@ _STDOUT_CONFIG: dict = {
78
142
  "skills_intro": "The following skills are available as slash commands or knowledge sources:",
79
143
  "skills_format": "headings",
80
144
  "skills_level": "###",
81
- "guidelines": ["quality"],
145
+ "guidelines": ["quality_standards"],
82
146
  }
83
147
 
84
148
 
@@ -90,11 +154,13 @@ def _instructions_file(content: str, *, apply_to: str,
90
154
  description: str = "") -> str:
91
155
  """Wrap markdown content with Copilot ``.instructions.md`` frontmatter."""
92
156
  lines = ["---"]
93
- lines.append(f'applyTo: "{apply_to}"')
157
+ lines.append(f"applyTo: {json.dumps(apply_to, ensure_ascii=False)}")
94
158
  if description:
95
- lines.append(f"description: {description}")
159
+ lines.append(f"description: {json.dumps(description, ensure_ascii=False)}")
96
160
  lines.append("---")
97
161
  lines.append("")
162
+ lines.append(MANAGED_MARKER)
163
+ lines.append("")
98
164
  lines.append(content.rstrip("\n"))
99
165
  lines.append("")
100
166
  return "\n".join(lines)
@@ -141,48 +207,245 @@ def _make_instruction_files() -> dict[str, callable]:
141
207
  # Prompt-file emission (.github/prompts/*.prompt.md)
142
208
  # ---------------------------------------------------------------------------
143
209
 
144
- def _prompt_file(description: str, body: str, *,
145
- agent: str | None = None) -> str:
210
+ def _prompt_file(description: str, body: str) -> str:
146
211
  """Wrap a skill body with Copilot ``.prompt.md`` frontmatter."""
147
212
  lines = ["---"]
148
- # Description is required for visibility in the slash menu.
149
- lines.append(f"description: {description}")
150
- if agent:
151
- lines.append(f"agent: {agent}")
213
+ lines.append(f"description: {json.dumps(description, ensure_ascii=False)}")
152
214
  lines.append("---")
153
215
  lines.append("")
216
+ lines.append(MANAGED_MARKER)
217
+ lines.append("")
154
218
  lines.append(body.rstrip("\n"))
155
219
  lines.append("")
156
220
  return "\n".join(lines)
157
221
 
158
222
 
159
- def _read_skill_body(skill_file: Path) -> str:
160
- """Read SKILL.md body after the closing frontmatter delimiter."""
223
+ def _legacy_prompt_file(description: str, body: str) -> str:
224
+ """Render the pre-native prompt format for safe in-place migration."""
225
+ return "\n".join([
226
+ "---",
227
+ f"description: {description}",
228
+ "---",
229
+ "",
230
+ body.rstrip("\n"),
231
+ "",
232
+ ])
233
+
234
+
235
+ def _read_markdown_body(markdown_file: Path) -> str:
236
+ """Read the body after the first frontmatter block without losing rules."""
237
+ text = markdown_file.read_text(encoding="utf-8")
238
+ lines = text.splitlines(keepends=True)
239
+ if not lines or lines[0].rstrip("\r\n") != "---":
240
+ return text.rstrip()
241
+ for index, line in enumerate(lines[1:], start=1):
242
+ if line.rstrip("\r\n") == "---":
243
+ return "".join(lines[index + 1:]).lstrip("\r\n").rstrip()
244
+ return text.rstrip()
245
+
246
+
247
+ def _read_legacy_prompt_body(skill_file: Path) -> str:
248
+ """Reproduce the previous prompt-body parser for exact migration only."""
161
249
  lines: list[str] = []
162
250
  fence_count = 0
163
- with open(skill_file, encoding="utf-8") as f:
164
- for line in f:
165
- stripped = line.rstrip("\n")
166
- if stripped == "---":
167
- fence_count += 1
168
- continue
169
- if fence_count >= 2:
170
- lines.append(line.rstrip("\n"))
251
+ for line in skill_file.read_text(encoding="utf-8").splitlines():
252
+ if line == "---":
253
+ fence_count += 1
254
+ continue
255
+ if fence_count >= 2:
256
+ lines.append(line)
171
257
  while lines and not lines[-1]:
172
258
  lines.pop()
173
259
  return "\n".join(lines)
174
260
 
175
261
 
176
- def _user_invocable_skills() -> list[tuple[str, str, str]]:
177
- """Return user-invocable skills as (name, description, body) tuples.
262
+ def _portable_copilot_body(body: str, *, include_execution_note: bool) -> str:
263
+ """Remove Claude-only interpolation and delegation APIs from markdown."""
264
+ body = _replace_agent_calls(body)
265
+ body = _TASK_CALL_RE.sub(
266
+ "Update or inspect progress using Copilot's current planning controls.",
267
+ body,
268
+ )
269
+ body = _replace_dynamic_context(body)
270
+ literal_replacements = {
271
+ "${CLAUDE_SKILL_DIR}/": "./",
272
+ "$CLAUDE_SKILL_DIR/": "./",
273
+ "${CLAUDE_SKILL_DIR}": "the installed ai-toolkit skill directory",
274
+ "CLAUDE_SKILL_DIR": "the installed ai-toolkit skill directory",
275
+ "$ARGUMENTS": "the user-supplied task details",
276
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "Copilot custom-agent support",
277
+ "Agent Teams": "Copilot custom agents",
278
+ "native Agent Tool": "native custom-agent delegation",
279
+ "Native Agent Tool": "Copilot custom-agent delegation",
280
+ "`Agent` tool": "Copilot custom agents",
281
+ "the Agent tool": "Copilot custom agents",
282
+ "via Agent tool": "with Copilot custom-agent delegation",
283
+ "Agent tool": "Copilot custom-agent delegation",
284
+ ".claude/agents/": ".github/agents/",
285
+ "~/.claude/tasks/": "Copilot's current task list",
286
+ "## 🚀 Native Copilot custom agents Integration": (
287
+ "## Copilot custom-agent integration"
288
+ ),
289
+ "Copilot custom agents is enabled (`Copilot custom-agent support=1`)": (
290
+ "Copilot custom-agent delegation is available"
291
+ ),
292
+ }
293
+ for token, replacement in literal_replacements.items():
294
+ body = body.replace(token, replacement)
295
+ api_replacements = {
296
+ "TeamCreate": "coordinate Copilot custom agents",
297
+ "TeamDelete": "finish coordinated custom-agent work",
298
+ "SendMessage": "steer a running custom agent",
299
+ "TaskCreate": "the planning controls available in Copilot",
300
+ "TaskList": "the planning controls available in Copilot",
301
+ "TaskUpdate": "the planning controls available in Copilot",
302
+ "TaskGet": "review delegated progress",
303
+ "TaskOutput": "collect delegated results",
304
+ "TaskStop": "stop delegated work",
305
+ "spawn_agent": "delegate work to a Copilot custom agent",
306
+ "send_input": "steer a running custom agent",
307
+ "wait_agent": "wait for delegated results",
308
+ "close_agent": "stop delegated work",
309
+ "update_plan": "the planning controls available in Copilot",
310
+ "fork_context": "appropriate inherited task context",
311
+ }
312
+ for token, replacement in api_replacements.items():
313
+ body = re.sub(rf"\b{token}\b", replacement, body)
314
+ body = re.sub(
315
+ r"\bagent_type\s*=",
316
+ "a suitable custom-agent role",
317
+ body,
318
+ )
319
+ body = re.sub(
320
+ r"\bview_skill\(\s*['\"]([^'\"]+)['\"]\s*\)",
321
+ lambda match: f"Load the `{match.group(1)}` skill if it is available",
322
+ body,
323
+ )
324
+ body = re.sub(
325
+ r"\bUse (?:Opus|Sonnet|Haiku)(?:\s+[0-9.]+)?\b",
326
+ "Use the model selected by the current Copilot client",
327
+ body,
328
+ )
329
+ body = body.replace(
330
+ ".github/agents/{name}.md",
331
+ ".github/agents/ai-toolkit-{name}.agent.md",
332
+ )
333
+ body = re.sub(
334
+ r"Hooks in `\.claude/hooks\.json` auto-enforce quality:\n"
335
+ r"(?:- .*\n){1,4}",
336
+ "Run repository quality gates explicitly before accepting delegated "
337
+ "work; do not assume Claude hook configuration applies to Copilot.\n",
338
+ body,
339
+ )
340
+
341
+ body = body.strip()
342
+ forbidden = _FORBIDDEN_COPILOT_BODY_RE.search(body)
343
+ if forbidden:
344
+ raise ValueError(f"Unsupported Copilot body token: {forbidden.group(0)}")
345
+ if not include_execution_note:
346
+ return body + "\n"
347
+ note = """## GitHub Copilot execution notes
348
+
349
+ - Treat the current user request as the task input for this prompt.
350
+ - Resolve `./` script paths from the installed ai-toolkit skill directory that
351
+ corresponds to this prompt, not from the repository root.
352
+ - Use Copilot's current custom-agent and planning controls without assuming a
353
+ particular internal tool signature."""
354
+ return f"{note}\n\n{body}\n"
355
+
356
+
357
+ def _replace_dynamic_context(body: str) -> str:
358
+ """Remove Claude's ``!`command``` prefix outside Markdown code spans."""
359
+ rendered: list[str] = []
360
+ in_fence = False
361
+ for line in body.splitlines(keepends=True):
362
+ stripped = line.lstrip()
363
+ if stripped.startswith("```"):
364
+ in_fence = not in_fence
365
+ rendered.append(line)
366
+ continue
367
+ if in_fence:
368
+ rendered.append(line)
369
+ continue
370
+
371
+ output: list[str] = []
372
+ index = 0
373
+ inline_delimiter = 0
374
+ while index < len(line):
375
+ if line[index] == "`":
376
+ run_end = index
377
+ while run_end < len(line) and line[run_end] == "`":
378
+ run_end += 1
379
+ run_length = run_end - index
380
+ if inline_delimiter == 0:
381
+ inline_delimiter = run_length
382
+ elif inline_delimiter == run_length:
383
+ inline_delimiter = 0
384
+ output.append(line[index:run_end])
385
+ index = run_end
386
+ continue
387
+ if inline_delimiter == 0 and line.startswith("!`", index):
388
+ closing = line.find("`", index + 2)
389
+ if closing != -1:
390
+ output.append(f"`{line[index + 2:closing]}`")
391
+ index = closing + 1
392
+ continue
393
+ output.append(line[index])
394
+ index += 1
395
+ rendered.append("".join(output))
396
+ return "".join(rendered)
178
397
 
179
- Only skills whose SKILL.md is suitable for slash-command invocation
180
- are returned. Knowledge-only skills (``user-invocable: false`` or
181
- ``disable-model-invocation: true``) are filtered out.
398
+
399
+ def _replace_agent_calls(body: str) -> str:
400
+ """Replace balanced Claude Agent calls without leaving argument fragments."""
401
+ rendered: list[str] = []
402
+ cursor = 0
403
+ while match := _AGENT_START_RE.search(body, cursor):
404
+ rendered.append(body[cursor:match.start()])
405
+ cursor = _balanced_call_end(body, match.end() - 1)
406
+ rendered.append(
407
+ "Delegate this independent work to a suitable Copilot custom agent."
408
+ )
409
+ rendered.append(body[cursor:])
410
+ return "".join(rendered)
411
+
412
+
413
+ def _balanced_call_end(text: str, opening_parenthesis: int) -> int:
414
+ """Return the first offset after a balanced, quote-aware call."""
415
+ depth = 1
416
+ quote: str | None = None
417
+ is_escaped = False
418
+ for index in range(opening_parenthesis + 1, len(text)):
419
+ character = text[index]
420
+ if quote is not None:
421
+ if is_escaped:
422
+ is_escaped = False
423
+ elif character == "\\":
424
+ is_escaped = True
425
+ elif character == quote:
426
+ quote = None
427
+ continue
428
+ if character in {"'", '"'}:
429
+ quote = character
430
+ elif character == "(":
431
+ depth += 1
432
+ elif character == ")":
433
+ depth -= 1
434
+ if depth == 0:
435
+ return index + 1
436
+ raise ValueError(f"Unbalanced Agent call at character {opening_parenthesis}")
437
+
438
+
439
+ def _user_invocable_skills() -> list[tuple[str, str, str, str]]:
440
+ """Return user-invocable skills and their current/legacy bodies.
441
+
442
+ Only skills whose SKILL.md is suitable for slash-command invocation are
443
+ returned. Knowledge-only skills (``user-invocable: false``) are filtered
444
+ out; user-invoked task skills may set ``disable-model-invocation: true``.
182
445
  """
183
446
  if not skills_dir.is_dir():
184
447
  return []
185
- result: list[tuple[str, str, str]] = []
448
+ result: list[tuple[str, str, str, str]] = []
186
449
  for skill_dir in sorted(skills_dir.iterdir()):
187
450
  if skill_dir.name.startswith("_") or not skill_dir.is_dir():
188
451
  continue
@@ -195,103 +458,929 @@ def _user_invocable_skills() -> list[tuple[str, str, str]]:
195
458
  continue
196
459
  # Honour the same visibility filter used by generate_opencode_commands
197
460
  user_invocable = frontmatter_field(skill_file, "user-invocable")
198
- disable_model = frontmatter_field(skill_file, "disable-model-invocation")
199
461
  if user_invocable == "false":
200
462
  continue
201
- # Task skills (disable-model-invocation: true) are still fine as
202
- # slash commands; knowledge skills with user-invocable: false are not.
203
- del disable_model # not used beyond inspection
204
- body = _read_skill_body(skill_file)
463
+ body = _read_markdown_body(skill_file)
205
464
  if not body:
206
465
  continue
207
- result.append((name, description, body))
466
+ result.append((
467
+ name,
468
+ description,
469
+ body,
470
+ _read_legacy_prompt_body(skill_file),
471
+ ))
208
472
  return result
209
473
 
210
474
 
475
+ # ---------------------------------------------------------------------------
476
+ # Native agent rendering and managed file synchronization
477
+ # ---------------------------------------------------------------------------
478
+
479
+ def _render_agent(agent_file: Path) -> tuple[str, str, str]:
480
+ """Return ``(name, description, Copilot agent markdown)``."""
481
+ name = frontmatter_field(agent_file, "name")
482
+ description = frontmatter_field(agent_file, "description")
483
+ if not name or not description or not _SAFE_NAME_RE.fullmatch(name):
484
+ raise ValueError(f"Invalid Copilot agent metadata: {agent_file}")
485
+
486
+ body = _portable_copilot_body(
487
+ _read_markdown_body(agent_file),
488
+ include_execution_note=False,
489
+ ).rstrip()
490
+ body_with_marker = f"{MANAGED_MARKER}\n\n{body}\n"
491
+ if len(body_with_marker.encode("utf-8")) > MAX_AGENT_BODY_BYTES:
492
+ raise ValueError(
493
+ f"Copilot agent body exceeds {MAX_AGENT_BODY_BYTES} bytes: {agent_file}"
494
+ )
495
+ content = "\n".join([
496
+ "---",
497
+ f"name: {json.dumps(name, ensure_ascii=False)}",
498
+ f"description: {json.dumps(description, ensure_ascii=False)}",
499
+ "---",
500
+ "",
501
+ body_with_marker.rstrip(),
502
+ "",
503
+ ])
504
+ return name, description, content
505
+
506
+
507
+ def _user_agent_names(directory: Path) -> set[str]:
508
+ """Collect logical names declared by non-managed Copilot agent files."""
509
+ names: set[str] = set()
510
+ for path in sorted(directory.glob("*.agent.md")):
511
+ if path.is_symlink():
512
+ _warn_preserved(path, "path is a symlink")
513
+ continue
514
+ if _is_managed(path):
515
+ continue
516
+ try:
517
+ name = frontmatter_field(path, "name")
518
+ except (OSError, UnicodeError) as error:
519
+ _warn_preserved(path, f"cannot read frontmatter ({error})")
520
+ continue
521
+ if name:
522
+ names.add(name)
523
+ else:
524
+ _warn_preserved(path, "missing a readable logical name")
525
+ return names
526
+
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
+
550
+ def _prepare_output_dir(base: Path, child_name: str) -> Path:
551
+ """Create a customization directory without following managed-root symlinks."""
552
+ output_dir = base / child_name
553
+ if base.is_symlink():
554
+ raise RuntimeError(f"Refusing symlinked Copilot customization root: {base}")
555
+ if output_dir.is_symlink():
556
+ raise RuntimeError(f"Refusing symlinked Copilot output directory: {output_dir}")
557
+ output_dir.mkdir(parents=True, exist_ok=True)
558
+ if base.is_symlink() or output_dir.is_symlink():
559
+ raise RuntimeError(f"Copilot output path became a symlink: {output_dir}")
560
+ return output_dir
561
+
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
+
571
+ def _is_managed(path: Path) -> bool:
572
+ if path.is_symlink() or not path.is_file():
573
+ return False
574
+ try:
575
+ return _is_managed_content(path.read_bytes())
576
+ except OSError:
577
+ return False
578
+
579
+
580
+ def _legacy_instructions_content(content: str) -> str:
581
+ """Recreate the previous generator output for exact safe migration."""
582
+ legacy = content.replace(f"{MANAGED_MARKER}\n\n", "", 1)
583
+ lines = legacy.splitlines()
584
+ for index, line in enumerate(lines):
585
+ if not line.startswith("description: "):
586
+ continue
587
+ value = line.removeprefix("description: ")
588
+ try:
589
+ lines[index] = f"description: {json.loads(value)}"
590
+ except json.JSONDecodeError:
591
+ pass
592
+ return "\n".join(lines) + "\n"
593
+
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
+
607
+ def _may_replace(path: Path, legacy_content: str | None) -> bool:
608
+ if path.is_symlink():
609
+ return False
610
+ if not path.exists() or _is_managed(path):
611
+ return True
612
+ if legacy_content is None or not path.is_file():
613
+ return False
614
+ try:
615
+ return _matches_legacy_managed(
616
+ path.name,
617
+ path.read_bytes(),
618
+ legacy_content,
619
+ )
620
+ except OSError:
621
+ return False
622
+
623
+
624
+ def _warn_preserved(path: Path, reason: str) -> None:
625
+ print(
626
+ f"Warning: preserving user Copilot file '{path}': {reason}",
627
+ file=sys.stderr,
628
+ )
629
+
630
+
631
+ def _stage_managed(
632
+ destination: Path,
633
+ content: str,
634
+ legacy_content: str | None,
635
+ ) -> Path | None:
636
+ """Stage a managed/legacy file beside its destination."""
637
+ if not _may_replace(destination, legacy_content):
638
+ _warn_preserved(destination, "destination is user-owned or a symlink")
639
+ return None
640
+
641
+ fd, temp_name = tempfile.mkstemp(
642
+ dir=destination.parent,
643
+ prefix=f".{destination.name}.",
644
+ suffix=".tmp",
645
+ )
646
+ temp_path = Path(temp_name)
647
+ try:
648
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
649
+ fd = -1
650
+ handle.write(content)
651
+ handle.flush()
652
+ os.fsync(handle.fileno())
653
+ return temp_path
654
+ except Exception:
655
+ temp_path.unlink(missing_ok=True)
656
+ raise
657
+ finally:
658
+ if fd >= 0:
659
+ os.close(fd)
660
+
661
+
662
+ def _sync_managed_files(
663
+ directory: Path,
664
+ desired: dict[str, tuple[str, str | None]],
665
+ *,
666
+ suffix: str,
667
+ label: str,
668
+ trusted_root: Path,
669
+ ) -> None:
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
+
689
+ staged: list[tuple[Path, Path, str | None, str]] = []
690
+ try:
691
+ for name, (content, legacy_content) in sorted(desired.items()):
692
+ destination = directory / name
693
+ temp_path = _stage_managed(destination, content, legacy_content)
694
+ if temp_path is not None:
695
+ staged.append((temp_path, destination, legacy_content, name))
696
+
697
+ for temp_path, destination, legacy_content, name in staged:
698
+ if not _may_replace(destination, legacy_content):
699
+ _warn_preserved(
700
+ destination,
701
+ "destination became user-owned during generation",
702
+ )
703
+ continue
704
+ os.replace(temp_path, destination)
705
+ print(f" Generated: {label}/{name}")
706
+ finally:
707
+ for temp_path, _, _, _ in staged:
708
+ temp_path.unlink(missing_ok=True)
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]] = []
753
+ for path in sorted(directory.glob(f"{PREFIX}*{suffix}")):
754
+ if path.name in keep:
755
+ continue
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
+ )
893
+
894
+
895
+ # ---------------------------------------------------------------------------
896
+ # Portable skill emission (.github/skills or user-level skills/)
897
+ # ---------------------------------------------------------------------------
898
+
899
+ def _render_skill_markdown(skill_dir: Path) -> tuple[str, str]:
900
+ """Return ``(logical_name, portable SKILL.md)`` for a source skill."""
901
+ skill_file = skill_dir / "SKILL.md"
902
+ name = frontmatter_field(skill_file, "name")
903
+ description = frontmatter_field(skill_file, "description")
904
+ if not name or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
905
+ raise ValueError(f"Invalid Copilot skill name: {skill_file}")
906
+ if not description:
907
+ raise ValueError(f"Missing Copilot skill description: {skill_file}")
908
+ body = _portable_copilot_body(
909
+ _read_markdown_body(skill_file),
910
+ include_execution_note=False,
911
+ ).rstrip()
912
+ execution_note = """## GitHub Copilot skill execution notes
913
+
914
+ - Resolve relative script, reference, template, and asset paths against this
915
+ skill directory. Copilot exposes the complete directory when loading a skill.
916
+ - Use Copilot's current custom-agent and planning controls without assuming a
917
+ Claude-specific tool signature."""
918
+ content = "\n".join([
919
+ "---",
920
+ f"name: {name}",
921
+ f"description: {json.dumps(description, ensure_ascii=False)}",
922
+ "---",
923
+ "",
924
+ MANAGED_MARKER,
925
+ "",
926
+ execution_note,
927
+ "",
928
+ body,
929
+ "",
930
+ ])
931
+ return name, content
932
+
933
+
934
+ def _skill_source_files(skill_dir: Path) -> dict[Path, tuple[bytes, int]]:
935
+ """Collect portable skill files, excluding generated bytecode and caches."""
936
+ files: dict[Path, tuple[bytes, int]] = {}
937
+ needs_detect_utils = False
938
+ for source in sorted(skill_dir.rglob("*")):
939
+ relative = source.relative_to(skill_dir)
940
+ if "__pycache__" in relative.parts or source.name == ".DS_Store":
941
+ continue
942
+ if source.is_symlink():
943
+ raise RuntimeError(f"Refusing symlinked Copilot skill source: {source}")
944
+ if not source.is_file() or source.name.endswith((".pyc", ".pyo")):
945
+ continue
946
+ if relative == Path("SKILL.md"):
947
+ continue
948
+ content = source.read_bytes()
949
+ if source.suffix == ".py":
950
+ text = content.decode("utf-8")
951
+ if "from _lib.detect_utils import" in text:
952
+ needs_detect_utils = True
953
+ text = text.replace(
954
+ "from _lib.detect_utils import",
955
+ "from detect_utils import",
956
+ )
957
+ content = text.encode("utf-8")
958
+ mode = source.stat().st_mode & 0o777
959
+ files[relative] = (content, mode or 0o644)
960
+
961
+ if needs_detect_utils:
962
+ helper = skills_dir / "_lib" / "detect_utils.py"
963
+ if helper.is_symlink() or not helper.is_file():
964
+ raise RuntimeError(f"Missing Copilot skill helper: {helper}")
965
+ files[Path("scripts/detect_utils.py")] = (
966
+ helper.read_bytes(),
967
+ helper.stat().st_mode & 0o777 or 0o644,
968
+ )
969
+ return files
970
+
971
+
972
+ def _is_managed_skill_dir(path: Path) -> bool:
973
+ skill_file = path / "SKILL.md"
974
+ return path.is_dir() and not path.is_symlink() and _is_managed(skill_file)
975
+
976
+
977
+ def _managed_skill_paths(path: Path) -> set[Path]:
978
+ manifest = path / SKILL_MANIFEST
979
+ if not manifest.is_file() or manifest.is_symlink():
980
+ return {Path("SKILL.md")}
981
+ try:
982
+ value = json.loads(manifest.read_text(encoding="utf-8"))
983
+ except (OSError, json.JSONDecodeError):
984
+ return {Path("SKILL.md")}
985
+ if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
986
+ return {Path("SKILL.md")}
987
+ result = {Path(item) for item in value}
988
+ result.add(Path(SKILL_MANIFEST))
989
+ return result
990
+
991
+
992
+ def _has_user_skill_extras(path: Path) -> bool:
993
+ managed = _managed_skill_paths(path)
994
+ return any(
995
+ item.is_file() and item.relative_to(path) not in managed
996
+ for item in path.rglob("*")
997
+ if not item.is_symlink()
998
+ ) or any(item.is_symlink() for item in path.rglob("*"))
999
+
1000
+
1001
+ def _copy_user_skill_extras(existing: Path, staging: Path,
1002
+ generated_paths: set[Path]) -> None:
1003
+ """Preserve files a user added inside a previously managed skill."""
1004
+ old_managed = _managed_skill_paths(existing)
1005
+ for source in sorted(existing.rglob("*")):
1006
+ relative = source.relative_to(existing)
1007
+ if relative in old_managed or source.is_dir():
1008
+ continue
1009
+ if source.is_symlink():
1010
+ raise RuntimeError(f"Refusing symlinked user Copilot skill asset: {source}")
1011
+ if not source.is_file():
1012
+ continue
1013
+ if relative in generated_paths:
1014
+ raise RuntimeError(
1015
+ f"Copilot skill update would overwrite a user asset: {source}"
1016
+ )
1017
+ destination = staging / relative
1018
+ destination.parent.mkdir(parents=True, exist_ok=True)
1019
+ shutil.copy2(source, destination)
1020
+
1021
+
1022
+ def _stage_skill(skills_root: Path, source_dir: Path,
1023
+ existing: Path | None) -> tuple[Path, str]:
1024
+ name, markdown = _render_skill_markdown(source_dir)
1025
+ assets = _skill_source_files(source_dir)
1026
+ generated_paths = {Path("SKILL.md"), *assets.keys(), Path(SKILL_MANIFEST)}
1027
+ staging = Path(tempfile.mkdtemp(
1028
+ dir=skills_root,
1029
+ prefix=f".ai-toolkit-{name}.",
1030
+ ))
1031
+ try:
1032
+ skill_file = staging / "SKILL.md"
1033
+ skill_file.write_text(markdown, encoding="utf-8")
1034
+ os.chmod(skill_file, 0o644)
1035
+ for relative, (content, mode) in sorted(assets.items()):
1036
+ destination = staging / relative
1037
+ destination.parent.mkdir(parents=True, exist_ok=True)
1038
+ destination.write_bytes(content)
1039
+ os.chmod(destination, mode)
1040
+ (staging / SKILL_MANIFEST).write_text(
1041
+ json.dumps(
1042
+ sorted(path.as_posix() for path in generated_paths),
1043
+ ensure_ascii=False,
1044
+ indent=2,
1045
+ ) + "\n",
1046
+ encoding="utf-8",
1047
+ )
1048
+ if existing is not None:
1049
+ _copy_user_skill_extras(existing, staging, generated_paths)
1050
+ return staging, name
1051
+ except Exception:
1052
+ shutil.rmtree(staging, ignore_errors=True)
1053
+ raise
1054
+
1055
+
1056
+ def _replace_skill_dir(staging: Path, destination: Path) -> None:
1057
+ """Replace one managed skill with a same-filesystem rollback directory."""
1058
+ backup: Path | None = None
1059
+ try:
1060
+ if destination.exists():
1061
+ if destination.is_symlink() or not _is_managed_skill_dir(destination):
1062
+ raise RuntimeError(
1063
+ f"Refusing user-owned Copilot skill collision: {destination}"
1064
+ )
1065
+ backup = Path(tempfile.mkdtemp(
1066
+ dir=destination.parent,
1067
+ prefix=f".{destination.name}.backup.",
1068
+ ))
1069
+ backup.rmdir()
1070
+ os.replace(destination, backup)
1071
+ os.replace(staging, destination)
1072
+ except Exception:
1073
+ if destination.exists() and backup is not None:
1074
+ shutil.rmtree(destination)
1075
+ if backup is not None and backup.exists():
1076
+ os.replace(backup, destination)
1077
+ raise
1078
+ finally:
1079
+ if staging.exists():
1080
+ shutil.rmtree(staging, ignore_errors=True)
1081
+ if backup is not None and backup.exists():
1082
+ shutil.rmtree(backup)
1083
+
1084
+
1085
+ def _user_skill_names(skills_root: Path) -> set[str]:
1086
+ names: set[str] = set()
1087
+ for child in sorted(skills_root.iterdir()):
1088
+ if child.is_symlink():
1089
+ _warn_preserved(child, "skill directory is a symlink")
1090
+ continue
1091
+ if not child.is_dir() or _is_managed_skill_dir(child):
1092
+ continue
1093
+ skill_file = child / "SKILL.md"
1094
+ if not skill_file.is_file() or skill_file.is_symlink():
1095
+ continue
1096
+ try:
1097
+ name = frontmatter_field(skill_file, "name")
1098
+ except (OSError, UnicodeError):
1099
+ continue
1100
+ if name:
1101
+ names.add(name)
1102
+ return names
1103
+
1104
+
1105
+ def _sync_copilot_skills(customization_root: Path, *, label: str) -> None:
1106
+ skill_root = _prepare_output_dir(customization_root, "skills")
1107
+ user_names = _user_skill_names(skill_root)
1108
+ expected_dirs: set[str] = set()
1109
+ for source_dir in sorted(skills_dir.iterdir()):
1110
+ if source_dir.name.startswith("_") or not (source_dir / "SKILL.md").is_file():
1111
+ continue
1112
+ logical_name = frontmatter_field(source_dir / "SKILL.md", "name")
1113
+ if not logical_name:
1114
+ raise ValueError(f"Missing Copilot skill name: {source_dir}")
1115
+ destination_name = f"{PREFIX}{logical_name}"
1116
+ destination = skill_root / destination_name
1117
+ if destination.is_symlink():
1118
+ raise RuntimeError(f"Refusing symlinked Copilot skill: {destination}")
1119
+ if logical_name in user_names:
1120
+ _warn_preserved(
1121
+ destination,
1122
+ f"logical name '{logical_name}' belongs to a user skill",
1123
+ )
1124
+ continue
1125
+ expected_dirs.add(destination_name)
1126
+ existing = destination if destination.exists() else None
1127
+ if existing is not None and not _is_managed_skill_dir(existing):
1128
+ raise RuntimeError(f"Refusing user-owned Copilot skill collision: {destination}")
1129
+ staging, rendered_name = _stage_skill(skill_root, source_dir, existing)
1130
+ if rendered_name != logical_name:
1131
+ shutil.rmtree(staging, ignore_errors=True)
1132
+ raise ValueError(f"Copilot skill name changed while rendering: {source_dir}")
1133
+ _replace_skill_dir(staging, destination)
1134
+
1135
+ for path in sorted(skill_root.glob(f"{PREFIX}*")):
1136
+ if path.name in expected_dirs or path.is_symlink() or not _is_managed_skill_dir(path):
1137
+ continue
1138
+ if _has_user_skill_extras(path):
1139
+ _warn_preserved(path, "stale managed skill contains user-added assets")
1140
+ continue
1141
+ shutil.rmtree(path)
1142
+ print(f" Removed stale: {label}/{path.name}")
1143
+ print(f" Generated: {label}/ ({len(expected_dirs)} portable skills)")
1144
+
1145
+
211
1146
  # ---------------------------------------------------------------------------
212
1147
  # Directory-mode generation
213
1148
  # ---------------------------------------------------------------------------
214
1149
 
215
- def _cleanup_prefixed(directory: Path, suffix: str,
216
- keep: set[str]) -> None:
217
- """Remove ai-toolkit-prefixed files in ``directory`` that aren't in ``keep``."""
218
- if not directory.is_dir():
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:
219
1246
  return
220
- for f in directory.iterdir():
221
- if f.name.startswith(PREFIX) and f.name.endswith(suffix) and f.name not in keep:
222
- f.unlink()
223
- rel = f.relative_to(directory.parent.parent) if len(directory.parents) >= 2 else f
224
- print(f" Removed stale: {rel}")
1247
+ _require_secure_cleanup()
1248
+ run_secure_transaction(
1249
+ [destination for destination, _ in candidates],
1250
+ lambda _transaction: None,
1251
+ )
225
1252
 
226
1253
 
227
1254
  def generate(target_dir: Path, *,
228
1255
  language_modules: list[str] | None = None,
229
1256
  rules_dir: Path | None = None,
1257
+ emit_agents: bool = True,
230
1258
  emit_prompts: bool = True,
231
1259
  emit_instructions: bool = True,
1260
+ emit_skills: bool = True,
1261
+ cleanup_disabled: bool = False,
232
1262
  config_root: Path | None = None) -> None:
233
- """Write Copilot path-specific instructions and prompt files.
1263
+ """Write Copilot instructions, custom agents, skills, and prompt files.
234
1264
 
235
1265
  By default writes to ``<target_dir>/.github/`` (project-local). Pass
236
1266
  ``config_root=~/.copilot`` for the Copilot CLI user-level global layout,
237
- where instructions land in ``~/.copilot/instructions/*.instructions.md``.
1267
+ where instructions and agents land below ``~/.copilot/``.
238
1268
 
239
1269
  ``.github/copilot-instructions.md`` is intentionally not written here —
240
1270
  the legacy ``main()`` entry point still emits it to stdout so existing
241
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.
242
1276
  """
243
1277
  github_dir = target_dir / ".github"
244
- instr_root = config_root if config_root is not None else github_dir
245
- instr_label = "~/.copilot/instructions" if config_root is not None else ".github/instructions"
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
+ )
1282
+ instr_root = customization_root
1283
+ instr_label = "$COPILOT_HOME/instructions" if config_root is not None else ".github/instructions"
1284
+ agent_label = "$COPILOT_HOME/agents" if config_root is not None else ".github/agents"
1285
+ skill_label = "$COPILOT_HOME/skills" if config_root is not None else ".github/skills"
246
1286
 
247
- if emit_instructions:
248
- instr_dir = instr_root / "instructions"
249
- instr_dir.mkdir(parents=True, exist_ok=True)
250
-
251
- instruction_files: dict[str, callable] = dict(_make_instruction_files())
252
-
253
- # Language-specific instructions (auto-applied by file glob)
254
- for filename, content_fn in build_language_rules(language_modules).items():
255
- lang = filename.removeprefix(f"{PREFIX}lang-").removesuffix(".md")
256
- globs = LANG_GLOBS.get(lang)
257
- apply_to = ",".join(globs) if globs else "**"
258
- new_name = f"{PREFIX}lang-{lang}.instructions.md"
259
- instruction_files[new_name] = (lambda fn, l, a: lambda: _instructions_file(
260
- fn(),
261
- apply_to=a,
262
- description=f"{l.title()} language rules",
263
- ))(content_fn, lang, apply_to)
264
-
265
- # User-registered custom rules (always-on)
266
- for filename, content_fn in build_registered_rules(rules_dir).items():
267
- stem = filename.removeprefix(f"{PREFIX}custom-").removesuffix(".md")
268
- new_name = f"{PREFIX}custom-{stem}.instructions.md"
269
- instruction_files[new_name] = (lambda fn, n: lambda: _instructions_file(
270
- fn(),
271
- apply_to="**",
272
- description=f"Custom rule: {n}",
273
- ))(content_fn, stem)
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
+ )
274
1308
 
275
- _cleanup_prefixed(instr_dir, ".instructions.md", set(instruction_files.keys()))
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()
276
1333
 
277
- for name, content_fn in instruction_files.items():
278
- (instr_dir / name).write_text(content_fn(), encoding="utf-8")
279
- print(f" Generated: {instr_label}/{name}")
1334
+ if emit_instructions:
1335
+ instr_dir = _prepare_output_dir(instr_root, "instructions")
1336
+ _sync_managed_files(
1337
+ instr_dir,
1338
+ desired_instructions,
1339
+ suffix=".instructions.md",
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,
1351
+ )
280
1352
 
281
- if emit_prompts:
282
- prompt_dir = github_dir / "prompts"
283
- prompt_dir.mkdir(parents=True, exist_ok=True)
1353
+ if emit_agents:
1354
+ agent_dir = _prepare_output_dir(customization_root, "agents")
1355
+ _sync_managed_files(
1356
+ agent_dir,
1357
+ desired_agents,
1358
+ suffix=".agent.md",
1359
+ label=agent_label,
1360
+ trusted_root=customization_trusted_root,
1361
+ )
284
1362
 
285
- skills = _user_invocable_skills()
286
- prompt_filenames: set[str] = set()
287
- for name, description, body in skills:
288
- filename = f"{PREFIX}{name}.prompt.md"
289
- prompt_filenames.add(filename)
290
- content = _prompt_file(description, body)
291
- (prompt_dir / filename).write_text(content, encoding="utf-8")
292
- print(f" Generated: .github/prompts/{filename}")
1363
+ if emit_skills:
1364
+ _sync_copilot_skills(customization_root, label=skill_label)
293
1365
 
294
- _cleanup_prefixed(prompt_dir, ".prompt.md", prompt_filenames)
1366
+ if emit_prompts:
1367
+ prompt_dir = _prepare_output_dir(github_dir, "prompts")
1368
+ _sync_managed_files(
1369
+ prompt_dir,
1370
+ desired_prompts,
1371
+ suffix=".prompt.md",
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,
1383
+ )
295
1384
 
296
1385
 
297
1386
  # ---------------------------------------------------------------------------
@@ -305,8 +1394,9 @@ def main() -> None:
305
1394
  (preserves the historical contract).
306
1395
 
307
1396
  With a directory argument: write the path-specific ``instructions/`` and
308
- ``prompts/`` files under ``<target>/.github/``. The caller is responsible
309
- for redirecting the stdout generator separately if they want the full set.
1397
+ native ``agents/`` and ``prompts/`` files under ``<target>/.github/``. The
1398
+ caller is responsible for redirecting the stdout generator separately if
1399
+ they want the full set.
310
1400
  """
311
1401
  if len(sys.argv) > 1:
312
1402
  target = Path(sys.argv[1])