@softspark/ai-toolkit 4.14.0 → 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.
- package/CHANGELOG.md +35 -0
- package/README.md +11 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- package/app/agents/fact-checker.md +1 -1
- package/app/hooks/_search-capability.sh +3 -2
- package/app/hooks/stop-search-check.sh +2 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
- package/kb/procedures/maintenance-sop.md +26 -13
- package/kb/procedures/release-verification-sop.md +41 -36
- package/kb/reference/architecture-overview.md +23 -7
- package/kb/reference/codex-cli-compatibility.md +96 -36
- package/kb/reference/extension-api.md +52 -9
- package/kb/reference/global-install-model.md +53 -21
- package/kb/reference/hooks-catalog.md +44 -8
- package/kb/reference/mcp-editor-compatibility.md +27 -6
- package/kb/reference/mcp-templates.md +12 -6
- package/kb/reference/opencode-compatibility.md +13 -7
- package/kb/reference/plugin-pack-conventions.md +7 -7
- package/kb/reference/skills-catalog.md +3 -3
- package/kb/reference/supported-tools-registry.md +19 -17
- package/kb/reference/windows-support.md +26 -3
- package/llms-full.txt +443 -180
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +448 -198
- package/scripts/dir_rules_shared.py +2 -11
- package/scripts/ecosystem_tools.json +29 -8
- package/scripts/emission.py +5 -91
- package/scripts/generate_agents_md.py +4 -87
- package/scripts/generate_codex.py +5 -95
- package/scripts/generate_codex_agents.py +242 -0
- package/scripts/generate_codex_hooks.py +648 -55
- package/scripts/generate_codex_skills.py +15 -6
- package/scripts/generate_copilot.py +771 -74
- package/scripts/generate_copilot_hooks.py +606 -0
- package/scripts/generate_cursor_hooks.py +453 -121
- package/scripts/generate_opencode_commands.py +4 -6
- package/scripts/inject_hook_cli.py +770 -205
- package/scripts/injection.py +102 -23
- package/scripts/install_steps/ai_tools.py +123 -83
- package/scripts/instruction_core.py +95 -0
- package/scripts/mcp_editors.py +934 -80
- package/scripts/mcp_manager.py +46 -26
- package/scripts/plugin.py +291 -114
- package/scripts/secure_fs.py +538 -0
- 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
|
|
5
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 (
|
|
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": ["
|
|
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
|
|
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
|
-
|
|
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
|
|
160
|
-
"""
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
177
|
-
"""
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
#
|
|
441
|
+
# Native agent rendering and managed file synchronization
|
|
213
442
|
# ---------------------------------------------------------------------------
|
|
214
443
|
|
|
215
|
-
def
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
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,
|
|
909
|
+
instruction_files[new_name] = (lambda fn, language_name, a: lambda: _instructions_file(
|
|
260
910
|
fn(),
|
|
261
911
|
apply_to=a,
|
|
262
|
-
description=f"{
|
|
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
|
-
|
|
276
|
-
|
|
925
|
+
desired_instructions: dict[str, tuple[str, str | None]] = {}
|
|
277
926
|
for name, content_fn in instruction_files.items():
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
309
|
-
for redirecting the stdout generator separately if
|
|
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])
|