@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.
- package/AGENTS.md +117 -0
- package/CHANGELOG.md +37 -0
- package/README.md +9 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- 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 +56 -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 +27 -3
- package/llms-full.txt +447 -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/copilot_legacy_hashes.json +338 -0
- 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 +1187 -97
- package/scripts/generate_copilot_hooks.py +723 -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 +136 -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,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
|
-
|
|
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 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": ["
|
|
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
|
|
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
|
-
|
|
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
|
|
160
|
-
"""
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
177
|
-
"""
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
|
309
|
-
for redirecting the stdout generator separately if
|
|
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])
|