@softspark/ai-toolkit 4.14.1 → 4.15.0

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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +11 -10
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/CLAUDE.md.template +3 -0
  5. package/app/hooks/_search-capability.sh +3 -2
  6. package/app/hooks/stop-search-check.sh +2 -1
  7. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  8. package/kb/procedures/maintenance-sop.md +26 -13
  9. package/kb/procedures/release-verification-sop.md +41 -36
  10. package/kb/reference/architecture-overview.md +23 -7
  11. package/kb/reference/codex-cli-compatibility.md +96 -36
  12. package/kb/reference/extension-api.md +52 -9
  13. package/kb/reference/global-install-model.md +53 -21
  14. package/kb/reference/hooks-catalog.md +44 -8
  15. package/kb/reference/mcp-editor-compatibility.md +27 -6
  16. package/kb/reference/mcp-templates.md +12 -6
  17. package/kb/reference/opencode-compatibility.md +13 -7
  18. package/kb/reference/plugin-pack-conventions.md +7 -7
  19. package/kb/reference/skills-catalog.md +3 -3
  20. package/kb/reference/supported-tools-registry.md +19 -17
  21. package/kb/reference/windows-support.md +26 -3
  22. package/llms-full.txt +443 -180
  23. package/llms.txt +1 -1
  24. package/manifest.json +1 -1
  25. package/package.json +2 -2
  26. package/scripts/codex_skill_adapter.py +448 -198
  27. package/scripts/dir_rules_shared.py +2 -11
  28. package/scripts/ecosystem_tools.json +29 -8
  29. package/scripts/emission.py +5 -91
  30. package/scripts/generate_agents_md.py +4 -87
  31. package/scripts/generate_codex.py +5 -95
  32. package/scripts/generate_codex_agents.py +242 -0
  33. package/scripts/generate_codex_hooks.py +648 -55
  34. package/scripts/generate_codex_skills.py +15 -6
  35. package/scripts/generate_copilot.py +771 -74
  36. package/scripts/generate_copilot_hooks.py +606 -0
  37. package/scripts/generate_cursor_hooks.py +453 -121
  38. package/scripts/generate_opencode_commands.py +4 -6
  39. package/scripts/inject_hook_cli.py +770 -205
  40. package/scripts/injection.py +102 -23
  41. package/scripts/install_steps/ai_tools.py +123 -83
  42. package/scripts/instruction_core.py +95 -0
  43. package/scripts/mcp_editors.py +934 -80
  44. package/scripts/mcp_manager.py +46 -26
  45. package/scripts/plugin.py +291 -114
  46. package/scripts/secure_fs.py +538 -0
  47. 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,39 @@ 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 json
48
+ import os
49
+ import re
50
+ import shutil
41
51
  import sys
52
+ import tempfile
42
53
  from pathlib import Path
43
54
 
44
55
  sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -60,6 +71,24 @@ from emission import (
60
71
  from frontmatter import frontmatter_field
61
72
  from generator_base import render_generator
62
73
 
74
+
75
+ MANAGED_MARKER = "<!-- ai-toolkit-managed: github-copilot -->"
76
+ SKILL_MANIFEST = ".ai-toolkit-managed-files"
77
+ MAX_AGENT_BODY_BYTES = 30_000
78
+ _AGENT_START_RE = re.compile(r"\bAgent\s*\(")
79
+ _TASK_CALL_RE = re.compile(
80
+ r"\bTask(?:Create|List|Update|Get|Output|Stop)\s*\([^)]*\)",
81
+ re.S,
82
+ )
83
+ _SAFE_NAME_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]*\Z")
84
+ _FORBIDDEN_COPILOT_BODY_RE = re.compile(
85
+ r"\$ARGUMENTS|CLAUDE_SKILL_DIR|CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS|"
86
+ r"\bAgent\s*\(|\bTask(?:Create|List|Update|Get|Output|Stop)\b|"
87
+ r"\b(?:TeamCreate|TeamDelete|SendMessage)\b|"
88
+ r"\b(?:spawn_agent|send_input|wait_agent|close_agent|update_plan|fork_context)\b|"
89
+ r"\bview_skill\s*\(|\b(?:subagent_type|agent_type)\s*="
90
+ )
91
+
63
92
  # ---------------------------------------------------------------------------
64
93
  # Shared configuration for the legacy stdout output
65
94
  # ---------------------------------------------------------------------------
@@ -78,7 +107,7 @@ _STDOUT_CONFIG: dict = {
78
107
  "skills_intro": "The following skills are available as slash commands or knowledge sources:",
79
108
  "skills_format": "headings",
80
109
  "skills_level": "###",
81
- "guidelines": ["quality"],
110
+ "guidelines": ["quality_standards"],
82
111
  }
83
112
 
84
113
 
@@ -90,11 +119,13 @@ def _instructions_file(content: str, *, apply_to: str,
90
119
  description: str = "") -> str:
91
120
  """Wrap markdown content with Copilot ``.instructions.md`` frontmatter."""
92
121
  lines = ["---"]
93
- lines.append(f'applyTo: "{apply_to}"')
122
+ lines.append(f"applyTo: {json.dumps(apply_to, ensure_ascii=False)}")
94
123
  if description:
95
- lines.append(f"description: {description}")
124
+ lines.append(f"description: {json.dumps(description, ensure_ascii=False)}")
96
125
  lines.append("---")
97
126
  lines.append("")
127
+ lines.append(MANAGED_MARKER)
128
+ lines.append("")
98
129
  lines.append(content.rstrip("\n"))
99
130
  lines.append("")
100
131
  return "\n".join(lines)
@@ -141,48 +172,245 @@ def _make_instruction_files() -> dict[str, callable]:
141
172
  # Prompt-file emission (.github/prompts/*.prompt.md)
142
173
  # ---------------------------------------------------------------------------
143
174
 
144
- def _prompt_file(description: str, body: str, *,
145
- agent: str | None = None) -> str:
175
+ def _prompt_file(description: str, body: str) -> str:
146
176
  """Wrap a skill body with Copilot ``.prompt.md`` frontmatter."""
147
177
  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}")
178
+ lines.append(f"description: {json.dumps(description, ensure_ascii=False)}")
152
179
  lines.append("---")
153
180
  lines.append("")
181
+ lines.append(MANAGED_MARKER)
182
+ lines.append("")
154
183
  lines.append(body.rstrip("\n"))
155
184
  lines.append("")
156
185
  return "\n".join(lines)
157
186
 
158
187
 
159
- def _read_skill_body(skill_file: Path) -> str:
160
- """Read SKILL.md body after the closing frontmatter delimiter."""
188
+ def _legacy_prompt_file(description: str, body: str) -> str:
189
+ """Render the pre-native prompt format for safe in-place migration."""
190
+ return "\n".join([
191
+ "---",
192
+ f"description: {description}",
193
+ "---",
194
+ "",
195
+ body.rstrip("\n"),
196
+ "",
197
+ ])
198
+
199
+
200
+ def _read_markdown_body(markdown_file: Path) -> str:
201
+ """Read the body after the first frontmatter block without losing rules."""
202
+ text = markdown_file.read_text(encoding="utf-8")
203
+ lines = text.splitlines(keepends=True)
204
+ if not lines or lines[0].rstrip("\r\n") != "---":
205
+ return text.rstrip()
206
+ for index, line in enumerate(lines[1:], start=1):
207
+ if line.rstrip("\r\n") == "---":
208
+ return "".join(lines[index + 1:]).lstrip("\r\n").rstrip()
209
+ return text.rstrip()
210
+
211
+
212
+ def _read_legacy_prompt_body(skill_file: Path) -> str:
213
+ """Reproduce the previous prompt-body parser for exact migration only."""
161
214
  lines: list[str] = []
162
215
  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"))
216
+ for line in skill_file.read_text(encoding="utf-8").splitlines():
217
+ if line == "---":
218
+ fence_count += 1
219
+ continue
220
+ if fence_count >= 2:
221
+ lines.append(line)
171
222
  while lines and not lines[-1]:
172
223
  lines.pop()
173
224
  return "\n".join(lines)
174
225
 
175
226
 
176
- def _user_invocable_skills() -> list[tuple[str, str, str]]:
177
- """Return user-invocable skills as (name, description, body) tuples.
227
+ def _portable_copilot_body(body: str, *, include_execution_note: bool) -> str:
228
+ """Remove Claude-only interpolation and delegation APIs from markdown."""
229
+ body = _replace_agent_calls(body)
230
+ body = _TASK_CALL_RE.sub(
231
+ "Update or inspect progress using Copilot's current planning controls.",
232
+ body,
233
+ )
234
+ body = _replace_dynamic_context(body)
235
+ literal_replacements = {
236
+ "${CLAUDE_SKILL_DIR}/": "./",
237
+ "$CLAUDE_SKILL_DIR/": "./",
238
+ "${CLAUDE_SKILL_DIR}": "the installed ai-toolkit skill directory",
239
+ "CLAUDE_SKILL_DIR": "the installed ai-toolkit skill directory",
240
+ "$ARGUMENTS": "the user-supplied task details",
241
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "Copilot custom-agent support",
242
+ "Agent Teams": "Copilot custom agents",
243
+ "native Agent Tool": "native custom-agent delegation",
244
+ "Native Agent Tool": "Copilot custom-agent delegation",
245
+ "`Agent` tool": "Copilot custom agents",
246
+ "the Agent tool": "Copilot custom agents",
247
+ "via Agent tool": "with Copilot custom-agent delegation",
248
+ "Agent tool": "Copilot custom-agent delegation",
249
+ ".claude/agents/": ".github/agents/",
250
+ "~/.claude/tasks/": "Copilot's current task list",
251
+ "## 🚀 Native Copilot custom agents Integration": (
252
+ "## Copilot custom-agent integration"
253
+ ),
254
+ "Copilot custom agents is enabled (`Copilot custom-agent support=1`)": (
255
+ "Copilot custom-agent delegation is available"
256
+ ),
257
+ }
258
+ for token, replacement in literal_replacements.items():
259
+ body = body.replace(token, replacement)
260
+ api_replacements = {
261
+ "TeamCreate": "coordinate Copilot custom agents",
262
+ "TeamDelete": "finish coordinated custom-agent work",
263
+ "SendMessage": "steer a running custom agent",
264
+ "TaskCreate": "the planning controls available in Copilot",
265
+ "TaskList": "the planning controls available in Copilot",
266
+ "TaskUpdate": "the planning controls available in Copilot",
267
+ "TaskGet": "review delegated progress",
268
+ "TaskOutput": "collect delegated results",
269
+ "TaskStop": "stop delegated work",
270
+ "spawn_agent": "delegate work to a Copilot custom agent",
271
+ "send_input": "steer a running custom agent",
272
+ "wait_agent": "wait for delegated results",
273
+ "close_agent": "stop delegated work",
274
+ "update_plan": "the planning controls available in Copilot",
275
+ "fork_context": "appropriate inherited task context",
276
+ }
277
+ for token, replacement in api_replacements.items():
278
+ body = re.sub(rf"\b{token}\b", replacement, body)
279
+ body = re.sub(
280
+ r"\bagent_type\s*=",
281
+ "a suitable custom-agent role",
282
+ body,
283
+ )
284
+ body = re.sub(
285
+ r"\bview_skill\(\s*['\"]([^'\"]+)['\"]\s*\)",
286
+ lambda match: f"Load the `{match.group(1)}` skill if it is available",
287
+ body,
288
+ )
289
+ body = re.sub(
290
+ r"\bUse (?:Opus|Sonnet|Haiku)(?:\s+[0-9.]+)?\b",
291
+ "Use the model selected by the current Copilot client",
292
+ body,
293
+ )
294
+ body = body.replace(
295
+ ".github/agents/{name}.md",
296
+ ".github/agents/ai-toolkit-{name}.agent.md",
297
+ )
298
+ body = re.sub(
299
+ r"Hooks in `\.claude/hooks\.json` auto-enforce quality:\n"
300
+ r"(?:- .*\n){1,4}",
301
+ "Run repository quality gates explicitly before accepting delegated "
302
+ "work; do not assume Claude hook configuration applies to Copilot.\n",
303
+ body,
304
+ )
305
+
306
+ body = body.strip()
307
+ forbidden = _FORBIDDEN_COPILOT_BODY_RE.search(body)
308
+ if forbidden:
309
+ raise ValueError(f"Unsupported Copilot body token: {forbidden.group(0)}")
310
+ if not include_execution_note:
311
+ return body + "\n"
312
+ note = """## GitHub Copilot execution notes
313
+
314
+ - Treat the current user request as the task input for this prompt.
315
+ - Resolve `./` script paths from the installed ai-toolkit skill directory that
316
+ corresponds to this prompt, not from the repository root.
317
+ - Use Copilot's current custom-agent and planning controls without assuming a
318
+ particular internal tool signature."""
319
+ return f"{note}\n\n{body}\n"
320
+
321
+
322
+ def _replace_dynamic_context(body: str) -> str:
323
+ """Remove Claude's ``!`command``` prefix outside Markdown code spans."""
324
+ rendered: list[str] = []
325
+ in_fence = False
326
+ for line in body.splitlines(keepends=True):
327
+ stripped = line.lstrip()
328
+ if stripped.startswith("```"):
329
+ in_fence = not in_fence
330
+ rendered.append(line)
331
+ continue
332
+ if in_fence:
333
+ rendered.append(line)
334
+ continue
178
335
 
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.
336
+ output: list[str] = []
337
+ index = 0
338
+ inline_delimiter = 0
339
+ while index < len(line):
340
+ if line[index] == "`":
341
+ run_end = index
342
+ while run_end < len(line) and line[run_end] == "`":
343
+ run_end += 1
344
+ run_length = run_end - index
345
+ if inline_delimiter == 0:
346
+ inline_delimiter = run_length
347
+ elif inline_delimiter == run_length:
348
+ inline_delimiter = 0
349
+ output.append(line[index:run_end])
350
+ index = run_end
351
+ continue
352
+ if inline_delimiter == 0 and line.startswith("!`", index):
353
+ closing = line.find("`", index + 2)
354
+ if closing != -1:
355
+ output.append(f"`{line[index + 2:closing]}`")
356
+ index = closing + 1
357
+ continue
358
+ output.append(line[index])
359
+ index += 1
360
+ rendered.append("".join(output))
361
+ return "".join(rendered)
362
+
363
+
364
+ def _replace_agent_calls(body: str) -> str:
365
+ """Replace balanced Claude Agent calls without leaving argument fragments."""
366
+ rendered: list[str] = []
367
+ cursor = 0
368
+ while match := _AGENT_START_RE.search(body, cursor):
369
+ rendered.append(body[cursor:match.start()])
370
+ cursor = _balanced_call_end(body, match.end() - 1)
371
+ rendered.append(
372
+ "Delegate this independent work to a suitable Copilot custom agent."
373
+ )
374
+ rendered.append(body[cursor:])
375
+ return "".join(rendered)
376
+
377
+
378
+ def _balanced_call_end(text: str, opening_parenthesis: int) -> int:
379
+ """Return the first offset after a balanced, quote-aware call."""
380
+ depth = 1
381
+ quote: str | None = None
382
+ is_escaped = False
383
+ for index in range(opening_parenthesis + 1, len(text)):
384
+ character = text[index]
385
+ if quote is not None:
386
+ if is_escaped:
387
+ is_escaped = False
388
+ elif character == "\\":
389
+ is_escaped = True
390
+ elif character == quote:
391
+ quote = None
392
+ continue
393
+ if character in {"'", '"'}:
394
+ quote = character
395
+ elif character == "(":
396
+ depth += 1
397
+ elif character == ")":
398
+ depth -= 1
399
+ if depth == 0:
400
+ return index + 1
401
+ raise ValueError(f"Unbalanced Agent call at character {opening_parenthesis}")
402
+
403
+
404
+ def _user_invocable_skills() -> list[tuple[str, str, str, str]]:
405
+ """Return user-invocable skills and their current/legacy bodies.
406
+
407
+ Only skills whose SKILL.md is suitable for slash-command invocation are
408
+ returned. Knowledge-only skills (``user-invocable: false``) are filtered
409
+ out; user-invoked task skills may set ``disable-model-invocation: true``.
182
410
  """
183
411
  if not skills_dir.is_dir():
184
412
  return []
185
- result: list[tuple[str, str, str]] = []
413
+ result: list[tuple[str, str, str, str]] = []
186
414
  for skill_dir in sorted(skills_dir.iterdir()):
187
415
  if skill_dir.name.startswith("_") or not skill_dir.is_dir():
188
416
  continue
@@ -195,58 +423,480 @@ def _user_invocable_skills() -> list[tuple[str, str, str]]:
195
423
  continue
196
424
  # Honour the same visibility filter used by generate_opencode_commands
197
425
  user_invocable = frontmatter_field(skill_file, "user-invocable")
198
- disable_model = frontmatter_field(skill_file, "disable-model-invocation")
199
426
  if user_invocable == "false":
200
427
  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)
428
+ body = _read_markdown_body(skill_file)
205
429
  if not body:
206
430
  continue
207
- result.append((name, description, body))
431
+ result.append((
432
+ name,
433
+ description,
434
+ body,
435
+ _read_legacy_prompt_body(skill_file),
436
+ ))
208
437
  return result
209
438
 
210
439
 
211
440
  # ---------------------------------------------------------------------------
212
- # Directory-mode generation
441
+ # Native agent rendering and managed file synchronization
213
442
  # ---------------------------------------------------------------------------
214
443
 
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():
219
- 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}")
444
+ def _render_agent(agent_file: Path) -> tuple[str, str, str]:
445
+ """Return ``(name, description, Copilot agent markdown)``."""
446
+ name = frontmatter_field(agent_file, "name")
447
+ description = frontmatter_field(agent_file, "description")
448
+ if not name or not description or not _SAFE_NAME_RE.fullmatch(name):
449
+ raise ValueError(f"Invalid Copilot agent metadata: {agent_file}")
450
+
451
+ body = _portable_copilot_body(
452
+ _read_markdown_body(agent_file),
453
+ include_execution_note=False,
454
+ ).rstrip()
455
+ body_with_marker = f"{MANAGED_MARKER}\n\n{body}\n"
456
+ if len(body_with_marker.encode("utf-8")) > MAX_AGENT_BODY_BYTES:
457
+ raise ValueError(
458
+ f"Copilot agent body exceeds {MAX_AGENT_BODY_BYTES} bytes: {agent_file}"
459
+ )
460
+ content = "\n".join([
461
+ "---",
462
+ f"name: {json.dumps(name, ensure_ascii=False)}",
463
+ f"description: {json.dumps(description, ensure_ascii=False)}",
464
+ "---",
465
+ "",
466
+ body_with_marker.rstrip(),
467
+ "",
468
+ ])
469
+ return name, description, content
470
+
471
+
472
+ def _user_agent_names(directory: Path) -> set[str]:
473
+ """Collect logical names declared by non-managed Copilot agent files."""
474
+ names: set[str] = set()
475
+ for path in sorted(directory.glob("*.agent.md")):
476
+ if path.is_symlink():
477
+ _warn_preserved(path, "path is a symlink")
478
+ continue
479
+ if _is_managed(path):
480
+ continue
481
+ try:
482
+ name = frontmatter_field(path, "name")
483
+ except (OSError, UnicodeError) as error:
484
+ _warn_preserved(path, f"cannot read frontmatter ({error})")
485
+ continue
486
+ if name:
487
+ names.add(name)
488
+ else:
489
+ _warn_preserved(path, "missing a readable logical name")
490
+ return names
491
+
492
+
493
+ def _prepare_output_dir(base: Path, child_name: str) -> Path:
494
+ """Create a customization directory without following managed-root symlinks."""
495
+ output_dir = base / child_name
496
+ if base.is_symlink():
497
+ raise RuntimeError(f"Refusing symlinked Copilot customization root: {base}")
498
+ if output_dir.is_symlink():
499
+ raise RuntimeError(f"Refusing symlinked Copilot output directory: {output_dir}")
500
+ output_dir.mkdir(parents=True, exist_ok=True)
501
+ if base.is_symlink() or output_dir.is_symlink():
502
+ raise RuntimeError(f"Copilot output path became a symlink: {output_dir}")
503
+ return output_dir
504
+
505
+
506
+ def _is_managed(path: Path) -> bool:
507
+ if path.is_symlink() or not path.is_file():
508
+ return False
509
+ try:
510
+ return MANAGED_MARKER in path.read_text(encoding="utf-8").splitlines()[:12]
511
+ except (OSError, UnicodeError):
512
+ return False
513
+
514
+
515
+ def _legacy_instructions_content(content: str) -> str:
516
+ """Recreate the previous generator output for exact safe migration."""
517
+ legacy = content.replace(f"{MANAGED_MARKER}\n\n", "", 1)
518
+ lines = legacy.splitlines()
519
+ for index, line in enumerate(lines):
520
+ if not line.startswith("description: "):
521
+ continue
522
+ value = line.removeprefix("description: ")
523
+ try:
524
+ lines[index] = f"description: {json.loads(value)}"
525
+ except json.JSONDecodeError:
526
+ pass
527
+ return "\n".join(lines) + "\n"
528
+
529
+
530
+ def _may_replace(path: Path, legacy_content: str | None) -> bool:
531
+ if path.is_symlink():
532
+ return False
533
+ if not path.exists() or _is_managed(path):
534
+ return True
535
+ if legacy_content is None or not path.is_file():
536
+ return False
537
+ try:
538
+ return path.read_text(encoding="utf-8") == legacy_content
539
+ except (OSError, UnicodeError):
540
+ return False
541
+
542
+
543
+ def _warn_preserved(path: Path, reason: str) -> None:
544
+ print(
545
+ f"Warning: preserving user Copilot file '{path}': {reason}",
546
+ file=sys.stderr,
547
+ )
548
+
549
+
550
+ def _stage_managed(
551
+ destination: Path,
552
+ content: str,
553
+ legacy_content: str | None,
554
+ ) -> Path | None:
555
+ """Stage a managed/legacy file beside its destination."""
556
+ if not _may_replace(destination, legacy_content):
557
+ _warn_preserved(destination, "destination is user-owned or a symlink")
558
+ return None
559
+
560
+ fd, temp_name = tempfile.mkstemp(
561
+ dir=destination.parent,
562
+ prefix=f".{destination.name}.",
563
+ suffix=".tmp",
564
+ )
565
+ temp_path = Path(temp_name)
566
+ try:
567
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
568
+ fd = -1
569
+ handle.write(content)
570
+ handle.flush()
571
+ os.fsync(handle.fileno())
572
+ return temp_path
573
+ except Exception:
574
+ temp_path.unlink(missing_ok=True)
575
+ raise
576
+ finally:
577
+ if fd >= 0:
578
+ os.close(fd)
579
+
580
+
581
+ def _sync_managed_files(
582
+ directory: Path,
583
+ desired: dict[str, tuple[str, str | None]],
584
+ *,
585
+ suffix: str,
586
+ label: str,
587
+ ) -> None:
588
+ """Write desired files first, then remove only stale managed files."""
589
+ staged: list[tuple[Path, Path, str | None, str]] = []
590
+ try:
591
+ for name, (content, legacy_content) in sorted(desired.items()):
592
+ destination = directory / name
593
+ temp_path = _stage_managed(destination, content, legacy_content)
594
+ if temp_path is not None:
595
+ staged.append((temp_path, destination, legacy_content, name))
596
+
597
+ for temp_path, destination, legacy_content, name in staged:
598
+ if not _may_replace(destination, legacy_content):
599
+ _warn_preserved(
600
+ destination,
601
+ "destination became user-owned during generation",
602
+ )
603
+ continue
604
+ os.replace(temp_path, destination)
605
+ print(f" Generated: {label}/{name}")
606
+ finally:
607
+ for temp_path, _, _, _ in staged:
608
+ temp_path.unlink(missing_ok=True)
609
+
610
+ for path in sorted(directory.glob(f"{PREFIX}*{suffix}")):
611
+ if path.name in desired or path.is_symlink() or not _is_managed(path):
612
+ continue
613
+ path.unlink()
614
+ print(f" Removed stale: {label}/{path.name}")
615
+
616
+
617
+ # ---------------------------------------------------------------------------
618
+ # Portable skill emission (.github/skills or user-level skills/)
619
+ # ---------------------------------------------------------------------------
620
+
621
+ def _render_skill_markdown(skill_dir: Path) -> tuple[str, str]:
622
+ """Return ``(logical_name, portable SKILL.md)`` for a source skill."""
623
+ skill_file = skill_dir / "SKILL.md"
624
+ name = frontmatter_field(skill_file, "name")
625
+ description = frontmatter_field(skill_file, "description")
626
+ if not name or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
627
+ raise ValueError(f"Invalid Copilot skill name: {skill_file}")
628
+ if not description:
629
+ raise ValueError(f"Missing Copilot skill description: {skill_file}")
630
+ body = _portable_copilot_body(
631
+ _read_markdown_body(skill_file),
632
+ include_execution_note=False,
633
+ ).rstrip()
634
+ execution_note = """## GitHub Copilot skill execution notes
635
+
636
+ - Resolve relative script, reference, template, and asset paths against this
637
+ skill directory. Copilot exposes the complete directory when loading a skill.
638
+ - Use Copilot's current custom-agent and planning controls without assuming a
639
+ Claude-specific tool signature."""
640
+ content = "\n".join([
641
+ "---",
642
+ f"name: {name}",
643
+ f"description: {json.dumps(description, ensure_ascii=False)}",
644
+ "---",
645
+ "",
646
+ MANAGED_MARKER,
647
+ "",
648
+ execution_note,
649
+ "",
650
+ body,
651
+ "",
652
+ ])
653
+ return name, content
654
+
655
+
656
+ def _skill_source_files(skill_dir: Path) -> dict[Path, tuple[bytes, int]]:
657
+ """Collect portable skill files, excluding generated bytecode and caches."""
658
+ files: dict[Path, tuple[bytes, int]] = {}
659
+ needs_detect_utils = False
660
+ for source in sorted(skill_dir.rglob("*")):
661
+ relative = source.relative_to(skill_dir)
662
+ if "__pycache__" in relative.parts or source.name == ".DS_Store":
663
+ continue
664
+ if source.is_symlink():
665
+ raise RuntimeError(f"Refusing symlinked Copilot skill source: {source}")
666
+ if not source.is_file() or source.name.endswith((".pyc", ".pyo")):
667
+ continue
668
+ if relative == Path("SKILL.md"):
669
+ continue
670
+ content = source.read_bytes()
671
+ if source.suffix == ".py":
672
+ text = content.decode("utf-8")
673
+ if "from _lib.detect_utils import" in text:
674
+ needs_detect_utils = True
675
+ text = text.replace(
676
+ "from _lib.detect_utils import",
677
+ "from detect_utils import",
678
+ )
679
+ content = text.encode("utf-8")
680
+ mode = source.stat().st_mode & 0o777
681
+ files[relative] = (content, mode or 0o644)
682
+
683
+ if needs_detect_utils:
684
+ helper = skills_dir / "_lib" / "detect_utils.py"
685
+ if helper.is_symlink() or not helper.is_file():
686
+ raise RuntimeError(f"Missing Copilot skill helper: {helper}")
687
+ files[Path("scripts/detect_utils.py")] = (
688
+ helper.read_bytes(),
689
+ helper.stat().st_mode & 0o777 or 0o644,
690
+ )
691
+ return files
692
+
693
+
694
+ def _is_managed_skill_dir(path: Path) -> bool:
695
+ skill_file = path / "SKILL.md"
696
+ return path.is_dir() and not path.is_symlink() and _is_managed(skill_file)
697
+
698
+
699
+ def _managed_skill_paths(path: Path) -> set[Path]:
700
+ manifest = path / SKILL_MANIFEST
701
+ if not manifest.is_file() or manifest.is_symlink():
702
+ return {Path("SKILL.md")}
703
+ try:
704
+ value = json.loads(manifest.read_text(encoding="utf-8"))
705
+ except (OSError, json.JSONDecodeError):
706
+ return {Path("SKILL.md")}
707
+ if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
708
+ return {Path("SKILL.md")}
709
+ result = {Path(item) for item in value}
710
+ result.add(Path(SKILL_MANIFEST))
711
+ return result
712
+
713
+
714
+ def _has_user_skill_extras(path: Path) -> bool:
715
+ managed = _managed_skill_paths(path)
716
+ return any(
717
+ item.is_file() and item.relative_to(path) not in managed
718
+ for item in path.rglob("*")
719
+ if not item.is_symlink()
720
+ ) or any(item.is_symlink() for item in path.rglob("*"))
721
+
722
+
723
+ def _copy_user_skill_extras(existing: Path, staging: Path,
724
+ generated_paths: set[Path]) -> None:
725
+ """Preserve files a user added inside a previously managed skill."""
726
+ old_managed = _managed_skill_paths(existing)
727
+ for source in sorted(existing.rglob("*")):
728
+ relative = source.relative_to(existing)
729
+ if relative in old_managed or source.is_dir():
730
+ continue
731
+ if source.is_symlink():
732
+ raise RuntimeError(f"Refusing symlinked user Copilot skill asset: {source}")
733
+ if not source.is_file():
734
+ continue
735
+ if relative in generated_paths:
736
+ raise RuntimeError(
737
+ f"Copilot skill update would overwrite a user asset: {source}"
738
+ )
739
+ destination = staging / relative
740
+ destination.parent.mkdir(parents=True, exist_ok=True)
741
+ shutil.copy2(source, destination)
742
+
743
+
744
+ def _stage_skill(skills_root: Path, source_dir: Path,
745
+ existing: Path | None) -> tuple[Path, str]:
746
+ name, markdown = _render_skill_markdown(source_dir)
747
+ assets = _skill_source_files(source_dir)
748
+ generated_paths = {Path("SKILL.md"), *assets.keys(), Path(SKILL_MANIFEST)}
749
+ staging = Path(tempfile.mkdtemp(
750
+ dir=skills_root,
751
+ prefix=f".ai-toolkit-{name}.",
752
+ ))
753
+ try:
754
+ skill_file = staging / "SKILL.md"
755
+ skill_file.write_text(markdown, encoding="utf-8")
756
+ os.chmod(skill_file, 0o644)
757
+ for relative, (content, mode) in sorted(assets.items()):
758
+ destination = staging / relative
759
+ destination.parent.mkdir(parents=True, exist_ok=True)
760
+ destination.write_bytes(content)
761
+ os.chmod(destination, mode)
762
+ (staging / SKILL_MANIFEST).write_text(
763
+ json.dumps(
764
+ sorted(path.as_posix() for path in generated_paths),
765
+ ensure_ascii=False,
766
+ indent=2,
767
+ ) + "\n",
768
+ encoding="utf-8",
769
+ )
770
+ if existing is not None:
771
+ _copy_user_skill_extras(existing, staging, generated_paths)
772
+ return staging, name
773
+ except Exception:
774
+ shutil.rmtree(staging, ignore_errors=True)
775
+ raise
776
+
777
+
778
+ def _replace_skill_dir(staging: Path, destination: Path) -> None:
779
+ """Replace one managed skill with a same-filesystem rollback directory."""
780
+ backup: Path | None = None
781
+ try:
782
+ if destination.exists():
783
+ if destination.is_symlink() or not _is_managed_skill_dir(destination):
784
+ raise RuntimeError(
785
+ f"Refusing user-owned Copilot skill collision: {destination}"
786
+ )
787
+ backup = Path(tempfile.mkdtemp(
788
+ dir=destination.parent,
789
+ prefix=f".{destination.name}.backup.",
790
+ ))
791
+ backup.rmdir()
792
+ os.replace(destination, backup)
793
+ os.replace(staging, destination)
794
+ except Exception:
795
+ if destination.exists() and backup is not None:
796
+ shutil.rmtree(destination)
797
+ if backup is not None and backup.exists():
798
+ os.replace(backup, destination)
799
+ raise
800
+ finally:
801
+ if staging.exists():
802
+ shutil.rmtree(staging, ignore_errors=True)
803
+ if backup is not None and backup.exists():
804
+ shutil.rmtree(backup)
805
+
806
+
807
+ def _user_skill_names(skills_root: Path) -> set[str]:
808
+ names: set[str] = set()
809
+ for child in sorted(skills_root.iterdir()):
810
+ if child.is_symlink():
811
+ _warn_preserved(child, "skill directory is a symlink")
812
+ continue
813
+ if not child.is_dir() or _is_managed_skill_dir(child):
814
+ continue
815
+ skill_file = child / "SKILL.md"
816
+ if not skill_file.is_file() or skill_file.is_symlink():
817
+ continue
818
+ try:
819
+ name = frontmatter_field(skill_file, "name")
820
+ except (OSError, UnicodeError):
821
+ continue
822
+ if name:
823
+ names.add(name)
824
+ return names
825
+
826
+
827
+ def _sync_copilot_skills(customization_root: Path, *, label: str) -> None:
828
+ skill_root = _prepare_output_dir(customization_root, "skills")
829
+ user_names = _user_skill_names(skill_root)
830
+ expected_dirs: set[str] = set()
831
+ for source_dir in sorted(skills_dir.iterdir()):
832
+ if source_dir.name.startswith("_") or not (source_dir / "SKILL.md").is_file():
833
+ continue
834
+ logical_name = frontmatter_field(source_dir / "SKILL.md", "name")
835
+ if not logical_name:
836
+ raise ValueError(f"Missing Copilot skill name: {source_dir}")
837
+ destination_name = f"{PREFIX}{logical_name}"
838
+ destination = skill_root / destination_name
839
+ if destination.is_symlink():
840
+ raise RuntimeError(f"Refusing symlinked Copilot skill: {destination}")
841
+ if logical_name in user_names:
842
+ _warn_preserved(
843
+ destination,
844
+ f"logical name '{logical_name}' belongs to a user skill",
845
+ )
846
+ continue
847
+ expected_dirs.add(destination_name)
848
+ existing = destination if destination.exists() else None
849
+ if existing is not None and not _is_managed_skill_dir(existing):
850
+ raise RuntimeError(f"Refusing user-owned Copilot skill collision: {destination}")
851
+ staging, rendered_name = _stage_skill(skill_root, source_dir, existing)
852
+ if rendered_name != logical_name:
853
+ shutil.rmtree(staging, ignore_errors=True)
854
+ raise ValueError(f"Copilot skill name changed while rendering: {source_dir}")
855
+ _replace_skill_dir(staging, destination)
856
+
857
+ for path in sorted(skill_root.glob(f"{PREFIX}*")):
858
+ if path.name in expected_dirs or path.is_symlink() or not _is_managed_skill_dir(path):
859
+ continue
860
+ if _has_user_skill_extras(path):
861
+ _warn_preserved(path, "stale managed skill contains user-added assets")
862
+ continue
863
+ shutil.rmtree(path)
864
+ print(f" Removed stale: {label}/{path.name}")
865
+ print(f" Generated: {label}/ ({len(expected_dirs)} portable skills)")
866
+
867
+
868
+ # ---------------------------------------------------------------------------
869
+ # Directory-mode generation
870
+ # ---------------------------------------------------------------------------
225
871
 
226
872
 
227
873
  def generate(target_dir: Path, *,
228
874
  language_modules: list[str] | None = None,
229
875
  rules_dir: Path | None = None,
876
+ emit_agents: bool = True,
230
877
  emit_prompts: bool = True,
231
878
  emit_instructions: bool = True,
879
+ emit_skills: bool = True,
232
880
  config_root: Path | None = None) -> None:
233
- """Write Copilot path-specific instructions and prompt files.
881
+ """Write Copilot instructions, custom agents, skills, and prompt files.
234
882
 
235
883
  By default writes to ``<target_dir>/.github/`` (project-local). Pass
236
884
  ``config_root=~/.copilot`` for the Copilot CLI user-level global layout,
237
- where instructions land in ``~/.copilot/instructions/*.instructions.md``.
885
+ where instructions and agents land below ``~/.copilot/``.
238
886
 
239
887
  ``.github/copilot-instructions.md`` is intentionally not written here —
240
888
  the legacy ``main()`` entry point still emits it to stdout so existing
241
889
  scripts (including ``ai-toolkit install``) keep working unchanged.
242
890
  """
243
891
  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"
892
+ customization_root = config_root if config_root is not None else github_dir
893
+ instr_root = customization_root
894
+ instr_label = "$COPILOT_HOME/instructions" if config_root is not None else ".github/instructions"
895
+ agent_label = "$COPILOT_HOME/agents" if config_root is not None else ".github/agents"
896
+ skill_label = "$COPILOT_HOME/skills" if config_root is not None else ".github/skills"
246
897
 
247
898
  if emit_instructions:
248
- instr_dir = instr_root / "instructions"
249
- instr_dir.mkdir(parents=True, exist_ok=True)
899
+ instr_dir = _prepare_output_dir(instr_root, "instructions")
250
900
 
251
901
  instruction_files: dict[str, callable] = dict(_make_instruction_files())
252
902
 
@@ -256,10 +906,10 @@ def generate(target_dir: Path, *,
256
906
  globs = LANG_GLOBS.get(lang)
257
907
  apply_to = ",".join(globs) if globs else "**"
258
908
  new_name = f"{PREFIX}lang-{lang}.instructions.md"
259
- instruction_files[new_name] = (lambda fn, l, a: lambda: _instructions_file(
909
+ instruction_files[new_name] = (lambda fn, language_name, a: lambda: _instructions_file(
260
910
  fn(),
261
911
  apply_to=a,
262
- description=f"{l.title()} language rules",
912
+ description=f"{language_name.title()} language rules",
263
913
  ))(content_fn, lang, apply_to)
264
914
 
265
915
  # User-registered custom rules (always-on)
@@ -272,26 +922,72 @@ def generate(target_dir: Path, *,
272
922
  description=f"Custom rule: {n}",
273
923
  ))(content_fn, stem)
274
924
 
275
- _cleanup_prefixed(instr_dir, ".instructions.md", set(instruction_files.keys()))
276
-
925
+ desired_instructions: dict[str, tuple[str, str | None]] = {}
277
926
  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}")
927
+ content = content_fn()
928
+ desired_instructions[name] = (
929
+ content,
930
+ _legacy_instructions_content(content),
931
+ )
932
+ _sync_managed_files(
933
+ instr_dir,
934
+ desired_instructions,
935
+ suffix=".instructions.md",
936
+ label=instr_label,
937
+ )
938
+
939
+ if emit_agents:
940
+ 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
+ _sync_managed_files(
958
+ agent_dir,
959
+ desired_agents,
960
+ suffix=".agent.md",
961
+ label=agent_label,
962
+ )
963
+
964
+ if emit_skills:
965
+ _sync_copilot_skills(customization_root, label=skill_label)
280
966
 
281
967
  if emit_prompts:
282
- prompt_dir = github_dir / "prompts"
283
- prompt_dir.mkdir(parents=True, exist_ok=True)
968
+ prompt_dir = _prepare_output_dir(github_dir, "prompts")
284
969
 
285
970
  skills = _user_invocable_skills()
286
- prompt_filenames: set[str] = set()
287
- for name, description, body in 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}")
288
975
  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}")
293
-
294
- _cleanup_prefixed(prompt_dir, ".prompt.md", prompt_filenames)
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
+ _sync_managed_files(
986
+ prompt_dir,
987
+ desired_prompts,
988
+ suffix=".prompt.md",
989
+ label=".github/prompts",
990
+ )
295
991
 
296
992
 
297
993
  # ---------------------------------------------------------------------------
@@ -305,8 +1001,9 @@ def main() -> None:
305
1001
  (preserves the historical contract).
306
1002
 
307
1003
  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.
1004
+ native ``agents/`` and ``prompts/`` files under ``<target>/.github/``. The
1005
+ caller is responsible for redirecting the stdout generator separately if
1006
+ they want the full set.
310
1007
  """
311
1008
  if len(sys.argv) > 1:
312
1009
  target = Path(sys.argv[1])