@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
package/scripts/injection.py
CHANGED
|
@@ -15,12 +15,35 @@ import re
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
_MARKER_RE = re.compile(
|
|
19
|
+
r"^<!-- TOOLKIT:(?P<section>.+) (?P<kind>START|END) -->$"
|
|
20
|
+
)
|
|
21
|
+
_EMPTY_LEGACY_MARKER_RE = re.compile(
|
|
22
|
+
r"^<!-- TOOLKIT: (?P<kind>START|END) -->$"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
18
26
|
# ---------------------------------------------------------------------------
|
|
19
27
|
# Markers
|
|
20
28
|
# ---------------------------------------------------------------------------
|
|
21
29
|
|
|
30
|
+
def _validate_section_name(section: str) -> str:
|
|
31
|
+
"""Validate a section name that can be represented by one marker line."""
|
|
32
|
+
if not section or "\n" in section or "\r" in section:
|
|
33
|
+
raise ValueError("section name must be non-empty and single-line")
|
|
34
|
+
return section
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _section_names_for_update(section: str) -> set[str]:
|
|
38
|
+
"""Return current and pre-Unicode marker names for update migration."""
|
|
39
|
+
section = _validate_section_name(section)
|
|
40
|
+
legacy_section = re.sub(r"[^a-zA-Z0-9_-]", "", section)
|
|
41
|
+
return {section, legacy_section}
|
|
42
|
+
|
|
43
|
+
|
|
22
44
|
def markers_start(section: str = "ai-toolkit") -> str:
|
|
23
45
|
"""Return the TOOLKIT start marker block."""
|
|
46
|
+
section = _validate_section_name(section)
|
|
24
47
|
return (
|
|
25
48
|
f"<!-- TOOLKIT:{section} START -->\n"
|
|
26
49
|
f"<!-- Auto-injected by ai-toolkit. Re-run to update. -->\n"
|
|
@@ -29,6 +52,7 @@ def markers_start(section: str = "ai-toolkit") -> str:
|
|
|
29
52
|
|
|
30
53
|
def markers_end(section: str = "ai-toolkit") -> str:
|
|
31
54
|
"""Return the TOOLKIT end marker."""
|
|
55
|
+
section = _validate_section_name(section)
|
|
32
56
|
return f"\n<!-- TOOLKIT:{section} END -->"
|
|
33
57
|
|
|
34
58
|
|
|
@@ -38,21 +62,72 @@ def markers_end(section: str = "ai-toolkit") -> str:
|
|
|
38
62
|
|
|
39
63
|
def strip_section(content: str, section: str) -> str:
|
|
40
64
|
"""Remove a TOOLKIT marker section from content."""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
return _strip_sections(content, {_validate_section_name(section)})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def strip_all_sections(content: str) -> str:
|
|
69
|
+
"""Remove every balanced TOOLKIT section and any orphan marker lines.
|
|
70
|
+
|
|
71
|
+
Balanced spans are discovered before content is removed, so nested legacy
|
|
72
|
+
sections are handled without leaving an outer END marker behind. An
|
|
73
|
+
unmatched START marker does not consume unrelated user content after it.
|
|
74
|
+
"""
|
|
75
|
+
return _strip_sections(content, None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _strip_sections(
|
|
79
|
+
content: str,
|
|
80
|
+
sections: set[str] | None,
|
|
81
|
+
*,
|
|
82
|
+
migrate_empty_legacy: bool = False,
|
|
83
|
+
) -> str:
|
|
84
|
+
lines = content.splitlines(keepends=True)
|
|
85
|
+
stack: list[tuple[str, int]] = []
|
|
86
|
+
marker_names: dict[int, str] = {}
|
|
87
|
+
intervals: list[tuple[int, int, str]] = []
|
|
88
|
+
|
|
89
|
+
for index, line in enumerate(lines):
|
|
90
|
+
marker_line = line.rstrip("\r\n")
|
|
91
|
+
match = _MARKER_RE.fullmatch(marker_line)
|
|
92
|
+
if match:
|
|
93
|
+
name = _validate_section_name(match.group("section"))
|
|
94
|
+
kind = match.group("kind")
|
|
95
|
+
elif migrate_empty_legacy:
|
|
96
|
+
empty_match = _EMPTY_LEGACY_MARKER_RE.fullmatch(marker_line)
|
|
97
|
+
if not empty_match:
|
|
98
|
+
continue
|
|
99
|
+
name = ""
|
|
100
|
+
kind = empty_match.group("kind")
|
|
101
|
+
else:
|
|
52
102
|
continue
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
103
|
+
marker_names[index] = name
|
|
104
|
+
if kind == "START":
|
|
105
|
+
stack.append((name, index))
|
|
106
|
+
elif stack and stack[-1][0] == name:
|
|
107
|
+
_, start = stack.pop()
|
|
108
|
+
intervals.append((start, index, name))
|
|
109
|
+
elif stack:
|
|
110
|
+
# Crossed or otherwise mismatched markers make every open span
|
|
111
|
+
# ambiguous. Keep their non-marker content instead of guessing.
|
|
112
|
+
stack.clear()
|
|
113
|
+
|
|
114
|
+
coverage_delta = [0] * (len(lines) + 1)
|
|
115
|
+
for start, end, name in intervals:
|
|
116
|
+
if sections is None or name in sections:
|
|
117
|
+
coverage_delta[start] += 1
|
|
118
|
+
coverage_delta[end + 1] -= 1
|
|
119
|
+
|
|
120
|
+
result: list[str] = []
|
|
121
|
+
coverage = 0
|
|
122
|
+
for index, line in enumerate(lines):
|
|
123
|
+
coverage += coverage_delta[index]
|
|
124
|
+
marker_name = marker_names.get(index)
|
|
125
|
+
remove_marker = marker_name is not None and (
|
|
126
|
+
sections is None or marker_name in sections
|
|
127
|
+
)
|
|
128
|
+
if coverage == 0 and not remove_marker:
|
|
129
|
+
result.append(line)
|
|
130
|
+
return "".join(result)
|
|
56
131
|
|
|
57
132
|
|
|
58
133
|
def trim_trailing_blanks(text: str) -> str:
|
|
@@ -98,8 +173,7 @@ def inject_section(
|
|
|
98
173
|
content_file = Path(content_file)
|
|
99
174
|
target_file = Path(target_file)
|
|
100
175
|
|
|
101
|
-
|
|
102
|
-
section = re.sub(r"[^a-zA-Z0-9_-]", "", section)
|
|
176
|
+
section = _validate_section_name(section)
|
|
103
177
|
|
|
104
178
|
# Create parent dir and target if missing
|
|
105
179
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -112,8 +186,13 @@ def inject_section(
|
|
|
112
186
|
# Read existing content
|
|
113
187
|
existing = target_file.read_text(encoding="utf-8")
|
|
114
188
|
|
|
115
|
-
# Strip
|
|
116
|
-
|
|
189
|
+
# Strip both the current marker and the legacy ASCII-sanitized marker.
|
|
190
|
+
section_names = _section_names_for_update(section)
|
|
191
|
+
existing = _strip_sections(
|
|
192
|
+
existing,
|
|
193
|
+
section_names,
|
|
194
|
+
migrate_empty_legacy="" in section_names,
|
|
195
|
+
)
|
|
117
196
|
existing = trim_trailing_blanks(existing)
|
|
118
197
|
|
|
119
198
|
# Read content to inject
|
|
@@ -125,12 +204,11 @@ def inject_section(
|
|
|
125
204
|
parts.append(existing)
|
|
126
205
|
parts.append("")
|
|
127
206
|
|
|
128
|
-
parts.append(
|
|
129
|
-
parts.append("<!-- Auto-injected by ai-toolkit. Re-run to update. -->")
|
|
207
|
+
parts.append(markers_start(section).rstrip("\n"))
|
|
130
208
|
parts.append("")
|
|
131
209
|
parts.append(new_content.rstrip("\n"))
|
|
132
210
|
parts.append("")
|
|
133
|
-
parts.append(
|
|
211
|
+
parts.append(markers_end(section).lstrip("\n"))
|
|
134
212
|
|
|
135
213
|
output = "\n".join(parts) + "\n"
|
|
136
214
|
output = collapse_blank_runs(output)
|
|
@@ -151,7 +229,7 @@ def inject_rule(rule_file: str | Path, target_dir: str | Path) -> str:
|
|
|
151
229
|
if not rule_file.is_file():
|
|
152
230
|
raise FileNotFoundError(f"Rule file not found: {rule_file}")
|
|
153
231
|
|
|
154
|
-
rule_name =
|
|
232
|
+
rule_name = _validate_section_name(rule_file.stem)
|
|
155
233
|
claude_md = target_dir / ".claude" / "CLAUDE.md"
|
|
156
234
|
claude_md.parent.mkdir(parents=True, exist_ok=True)
|
|
157
235
|
|
|
@@ -170,7 +248,8 @@ def remove_rule_section(rule_name: str, target_dir: str | Path) -> bool:
|
|
|
170
248
|
return False
|
|
171
249
|
|
|
172
250
|
content = claude_md.read_text(encoding="utf-8")
|
|
173
|
-
|
|
251
|
+
rule_name = _validate_section_name(rule_name)
|
|
252
|
+
start_marker = markers_start(rule_name).splitlines()[0]
|
|
174
253
|
|
|
175
254
|
if start_marker not in content:
|
|
176
255
|
return False
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
"""Install global and project-local AI tool configs."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
|
-
from _common import app_dir, inject_section,
|
|
9
|
+
from _common import app_dir, inject_section, toolkit_dir
|
|
9
10
|
from codex_skill_adapter import (
|
|
10
11
|
cleanup_codex_skills,
|
|
12
|
+
prepare_codex_skills_dir,
|
|
11
13
|
sync_codex_skill,
|
|
14
|
+
unmanaged_codex_skill_names,
|
|
12
15
|
)
|
|
13
16
|
from mcp_editors import sync_project_mcp_to_editors
|
|
14
17
|
from injection import (
|
|
15
18
|
collapse_blank_runs as _collapse_blank_runs,
|
|
16
|
-
|
|
19
|
+
strip_all_sections as _strip_all_sections,
|
|
17
20
|
trim_trailing_blanks as _trim_trailing_blanks,
|
|
18
21
|
)
|
|
19
22
|
|
|
@@ -141,7 +144,9 @@ def install_ai_tools(target_dir: Path, rules_dir: Path,
|
|
|
141
144
|
|
|
142
145
|
if "codex" in eds:
|
|
143
146
|
if dry_run:
|
|
144
|
-
print(" Would inject:
|
|
147
|
+
print(" Would inject: $CODEX_HOME/AGENTS.md, $CODEX_HOME/agents/, "
|
|
148
|
+
"$CODEX_HOME/hooks.json")
|
|
149
|
+
print(" Would generate: ~/.agents/skills/ (shared Codex skill discovery)")
|
|
145
150
|
else:
|
|
146
151
|
_install_codex_global(target_dir, rules_dir)
|
|
147
152
|
installed.append("codex")
|
|
@@ -166,12 +171,20 @@ def install_ai_tools(target_dir: Path, rules_dir: Path,
|
|
|
166
171
|
installed.append("cursor")
|
|
167
172
|
|
|
168
173
|
if "copilot" in eds:
|
|
169
|
-
# Copilot CLI reads
|
|
170
|
-
#
|
|
171
|
-
|
|
174
|
+
# Copilot CLI reads every personal customization from its active config
|
|
175
|
+
# root. COPILOT_HOME replaces ~/.copilot rather than extending it.
|
|
176
|
+
from generate_copilot_hooks import copilot_home
|
|
177
|
+
|
|
178
|
+
copilot_root = copilot_home(target_dir)
|
|
179
|
+
if copilot_root.is_symlink():
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
f"Refusing symlinked Copilot configuration root: {copilot_root}"
|
|
182
|
+
)
|
|
172
183
|
if dry_run:
|
|
173
|
-
print(" Would inject:
|
|
174
|
-
print(" Would generate:
|
|
184
|
+
print(" Would inject: $COPILOT_HOME/copilot-instructions.md")
|
|
185
|
+
print(" Would generate: $COPILOT_HOME/{instructions,agents,skills}/")
|
|
186
|
+
if add_hooks:
|
|
187
|
+
print(" Would generate: $COPILOT_HOME/hooks/ai-toolkit.json")
|
|
175
188
|
else:
|
|
176
189
|
inject_with_rules(
|
|
177
190
|
"generate_copilot.py",
|
|
@@ -181,6 +194,12 @@ def install_ai_tools(target_dir: Path, rules_dir: Path,
|
|
|
181
194
|
_try_generator("generate_copilot", target_dir,
|
|
182
195
|
rules_dir=rules_dir, config_root=copilot_root,
|
|
183
196
|
emit_prompts=False)
|
|
197
|
+
if add_hooks:
|
|
198
|
+
_try_generator(
|
|
199
|
+
"generate_copilot_hooks",
|
|
200
|
+
target_dir,
|
|
201
|
+
config_root=copilot_root,
|
|
202
|
+
)
|
|
184
203
|
installed.append("copilot")
|
|
185
204
|
|
|
186
205
|
if "antigravity" in eds:
|
|
@@ -203,20 +222,34 @@ def install_ai_tools(target_dir: Path, rules_dir: Path,
|
|
|
203
222
|
return installed
|
|
204
223
|
|
|
205
224
|
|
|
225
|
+
def _resolve_global_codex_home(target_dir: Path) -> Path:
|
|
226
|
+
"""Resolve the active Codex user root without changing project paths."""
|
|
227
|
+
configured = os.environ.get("CODEX_HOME")
|
|
228
|
+
if not configured:
|
|
229
|
+
return target_dir / ".codex"
|
|
230
|
+
|
|
231
|
+
codex_home = Path(configured).expanduser()
|
|
232
|
+
if not codex_home.is_absolute():
|
|
233
|
+
raise RuntimeError("CODEX_HOME must be an absolute path")
|
|
234
|
+
if not codex_home.is_dir():
|
|
235
|
+
raise RuntimeError("Configured CODEX_HOME must already exist")
|
|
236
|
+
if codex_home.is_symlink():
|
|
237
|
+
raise RuntimeError(f"Refusing symlinked CODEX_HOME: {codex_home}")
|
|
238
|
+
return codex_home
|
|
239
|
+
|
|
240
|
+
|
|
206
241
|
def _install_codex_global(target_dir: Path, rules_dir: Path) -> None:
|
|
207
|
-
"""Install Codex at the
|
|
242
|
+
"""Install Codex at the active user-level ``CODEX_HOME`` layer.
|
|
208
243
|
|
|
209
244
|
Creates:
|
|
210
|
-
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
is only loaded in the degenerate case where a session's cwd is $HOME.
|
|
214
|
-
- ~/.agents/skills/* (skill symlinks; a documented Codex skill dir)
|
|
215
|
-
- ~/.codex/hooks.json (lifecycle hooks)
|
|
245
|
+
- ``$CODEX_HOME/AGENTS.md`` (defaults to ``~/.codex/AGENTS.md``)
|
|
246
|
+
- ``$CODEX_HOME/agents/*`` and ``$CODEX_HOME/hooks.json``
|
|
247
|
+
- ``~/.agents/skills/*`` (the documented shared user-skill path)
|
|
216
248
|
"""
|
|
249
|
+
codex_home = _resolve_global_codex_home(target_dir)
|
|
217
250
|
inject_with_rules(
|
|
218
251
|
"generate_codex.py",
|
|
219
|
-
|
|
252
|
+
codex_home / "AGENTS.md",
|
|
220
253
|
rules_dir,
|
|
221
254
|
)
|
|
222
255
|
|
|
@@ -226,17 +259,22 @@ def _install_codex_global(target_dir: Path, rules_dir: Path) -> None:
|
|
|
226
259
|
if _strip_toolkit_sections(target_dir / "AGENTS.md"):
|
|
227
260
|
print(" Migrated: removed stale ai-toolkit section from ~/AGENTS.md")
|
|
228
261
|
|
|
229
|
-
override =
|
|
262
|
+
override = codex_home / "AGENTS.override.md"
|
|
230
263
|
if override.is_file() and override.read_text(encoding="utf-8").strip():
|
|
231
264
|
print(
|
|
232
|
-
" Warning:
|
|
233
|
-
"
|
|
265
|
+
f" Warning: {override} exists and takes precedence over "
|
|
266
|
+
f"{codex_home / 'AGENTS.md'} — toolkit rules will be masked."
|
|
234
267
|
)
|
|
235
268
|
|
|
236
269
|
from generate_codex_hooks import generate as gen_codex_hooks
|
|
237
|
-
gen_codex_hooks(
|
|
238
|
-
|
|
270
|
+
gen_codex_hooks(
|
|
271
|
+
target_dir,
|
|
272
|
+
global_install=True,
|
|
273
|
+
codex_home=codex_home,
|
|
274
|
+
)
|
|
275
|
+
print(f" Created: {codex_home / 'hooks.json'}")
|
|
239
276
|
|
|
277
|
+
_install_codex_agents(target_dir, config_root=codex_home)
|
|
240
278
|
_install_codex_skills(target_dir)
|
|
241
279
|
|
|
242
280
|
|
|
@@ -506,14 +544,9 @@ def inject_with_rules(
|
|
|
506
544
|
|
|
507
545
|
existing = target_file.read_text(encoding="utf-8")
|
|
508
546
|
# Strip ALL toolkit sections from existing — generated output is the
|
|
509
|
-
# complete source of truth (includes ai-toolkit block + custom rules)
|
|
510
|
-
|
|
511
|
-
existing =
|
|
512
|
-
r"<!-- TOOLKIT:[^ ]+ START -->.*?<!-- TOOLKIT:[^ ]+ END -->\n?",
|
|
513
|
-
"",
|
|
514
|
-
existing,
|
|
515
|
-
flags=re.DOTALL,
|
|
516
|
-
)
|
|
547
|
+
# complete source of truth (includes ai-toolkit block + custom rules).
|
|
548
|
+
# The shared parser also repairs nested legacy sections and orphan markers.
|
|
549
|
+
existing = _strip_all_sections(existing)
|
|
517
550
|
existing = _trim_trailing_blanks(existing)
|
|
518
551
|
existing = existing.lstrip("\n")
|
|
519
552
|
|
|
@@ -540,15 +573,8 @@ def _strip_toolkit_sections(target_file: Path) -> bool:
|
|
|
540
573
|
"""
|
|
541
574
|
if not target_file.is_file():
|
|
542
575
|
return False
|
|
543
|
-
import re
|
|
544
|
-
|
|
545
576
|
original = target_file.read_text(encoding="utf-8")
|
|
546
|
-
stripped =
|
|
547
|
-
r"<!-- TOOLKIT:[^ ]+ START -->.*?<!-- TOOLKIT:[^ ]+ END -->\n?",
|
|
548
|
-
"",
|
|
549
|
-
original,
|
|
550
|
-
flags=re.DOTALL,
|
|
551
|
-
)
|
|
577
|
+
stripped = _strip_all_sections(original)
|
|
552
578
|
if stripped == original:
|
|
553
579
|
return False
|
|
554
580
|
stripped = stripped.lstrip("\n")
|
|
@@ -559,37 +585,14 @@ def _strip_toolkit_sections(target_file: Path) -> bool:
|
|
|
559
585
|
return True
|
|
560
586
|
|
|
561
587
|
|
|
562
|
-
def
|
|
563
|
-
"""
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
parts.append(existing)
|
|
571
|
-
parts.append("")
|
|
572
|
-
parts.extend([
|
|
573
|
-
f"<!-- TOOLKIT:{section} START -->",
|
|
574
|
-
"<!-- Auto-injected by ai-toolkit. Re-run to update. -->",
|
|
575
|
-
"",
|
|
576
|
-
text.rstrip("\n"),
|
|
577
|
-
"",
|
|
578
|
-
f"<!-- TOOLKIT:{section} END -->",
|
|
579
|
-
])
|
|
580
|
-
|
|
581
|
-
output = _collapse_blank_runs("\n".join(parts) + "\n").lstrip("\n")
|
|
582
|
-
target_file.write_text(output, encoding="utf-8")
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
def _install_copilot_agents_md(cwd: Path) -> None:
|
|
586
|
-
"""Emit root AGENTS.md for GitHub Copilot without clobbering other tools."""
|
|
587
|
-
generated = run_script("generate_agents_md.py", capture=True)
|
|
588
|
-
if not generated.strip():
|
|
589
|
-
print(" ERROR: generate_agents_md.py produced no output")
|
|
590
|
-
return
|
|
591
|
-
_inject_text_section(cwd / "AGENTS.md", "copilot-agents", generated)
|
|
592
|
-
print(" Updated: AGENTS.md (Copilot agent instructions)")
|
|
588
|
+
def _install_copilot_agents_md(cwd: Path, rules_dir: Path) -> None:
|
|
589
|
+
"""Emit the same effective AGENTS.md used by Codex, including rules."""
|
|
590
|
+
inject_with_rules(
|
|
591
|
+
"generate_codex.py",
|
|
592
|
+
cwd / "AGENTS.md",
|
|
593
|
+
rules_dir,
|
|
594
|
+
)
|
|
595
|
+
print(" Updated: AGENTS.md (shared Codex/Copilot instructions)")
|
|
593
596
|
|
|
594
597
|
|
|
595
598
|
def run_script(script_name: str, *args: str, capture: bool = False) -> str:
|
|
@@ -617,6 +620,12 @@ ALL_EDITORS = [
|
|
|
617
620
|
# Map of project files/dirs → editor names for auto-detection
|
|
618
621
|
_EDITOR_MARKERS: dict[str, str] = {
|
|
619
622
|
".github/copilot-instructions.md": "copilot",
|
|
623
|
+
".github/instructions": "copilot",
|
|
624
|
+
".github/prompts": "copilot",
|
|
625
|
+
".github/agents": "copilot",
|
|
626
|
+
".github/skills": "copilot",
|
|
627
|
+
".github/hooks": "copilot",
|
|
628
|
+
".github/mcp.json": "copilot",
|
|
620
629
|
".cursorrules": "cursor",
|
|
621
630
|
".cursor/rules": "cursor",
|
|
622
631
|
".windsurfrules": "windsurf",
|
|
@@ -740,7 +749,7 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
|
|
|
740
749
|
if language_modules:
|
|
741
750
|
print(f" Would inject language rules: {', '.join(language_modules)}")
|
|
742
751
|
if merged_config:
|
|
743
|
-
print(
|
|
752
|
+
print(" Would apply merged config from extends")
|
|
744
753
|
return
|
|
745
754
|
|
|
746
755
|
(cwd / ".claude").mkdir(parents=True, exist_ok=True)
|
|
@@ -864,7 +873,7 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
|
|
|
864
873
|
if meta:
|
|
865
874
|
state_file = cwd / ".softspark-toolkit-extends.json"
|
|
866
875
|
state_file.write_text(_json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
|
867
|
-
print(
|
|
876
|
+
print(" Saved: .softspark-toolkit-extends.json (resolution metadata)")
|
|
868
877
|
|
|
869
878
|
|
|
870
879
|
def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
|
|
@@ -914,7 +923,7 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
914
923
|
"when their triggers match -- you do not need to Read them manually."
|
|
915
924
|
)
|
|
916
925
|
if langs:
|
|
917
|
-
skill_names = ", ".join(f"`{
|
|
926
|
+
skill_names = ", ".join(f"`{language}-rules`" for language in langs)
|
|
918
927
|
lines.append("")
|
|
919
928
|
lines.append(f"Detected languages: {skill_names}.")
|
|
920
929
|
|
|
@@ -1031,8 +1040,11 @@ def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
|
|
|
1031
1040
|
print(" Would migrate: remove undocumented .devin/skills toolkit pointer")
|
|
1032
1041
|
|
|
1033
1042
|
# Profile-driven extras (matrix in kb/reference/global-install-model.md)
|
|
1034
|
-
if "copilot" in eds
|
|
1035
|
-
print(" Would generate: .github/
|
|
1043
|
+
if "copilot" in eds:
|
|
1044
|
+
print(" Would generate: .github/agents/ + .github/skills/")
|
|
1045
|
+
if add_copilot_dir:
|
|
1046
|
+
print(" Would generate: .github/instructions/ + .github/prompts/ + "
|
|
1047
|
+
".github/hooks/ (profile >= standard)")
|
|
1036
1048
|
if "gemini" in eds and add_gemini_hooks:
|
|
1037
1049
|
print(" Would generate: .gemini/settings.json hooks (profile >= standard)")
|
|
1038
1050
|
if add_native_surfaces:
|
|
@@ -1048,6 +1060,7 @@ def _install_local_dry_run(reset: bool, editors: list[str] | None = None,
|
|
|
1048
1060
|
if "gemini" in eds:
|
|
1049
1061
|
print(" Would generate: .gemini/commands/ + .gemini/skills/ (profile=full)")
|
|
1050
1062
|
if "codex" in eds:
|
|
1063
|
+
print(" Would generate: .codex/agents/ native agents")
|
|
1051
1064
|
print(" Would generate: .agents/skills/ Codex skills")
|
|
1052
1065
|
if codex_skills:
|
|
1053
1066
|
print(" Would refresh: .agents/skills/ via --codex-skills")
|
|
@@ -1158,8 +1171,8 @@ def _install_codex_skills(cwd: Path) -> None:
|
|
|
1158
1171
|
if not skills_src.is_dir():
|
|
1159
1172
|
return
|
|
1160
1173
|
|
|
1161
|
-
skills_dst = cwd
|
|
1162
|
-
skills_dst
|
|
1174
|
+
skills_dst = prepare_codex_skills_dir(cwd)
|
|
1175
|
+
user_names = unmanaged_codex_skill_names(skills_dst, skills_src)
|
|
1163
1176
|
|
|
1164
1177
|
linked = 0
|
|
1165
1178
|
adapted = 0
|
|
@@ -1170,6 +1183,9 @@ def _install_codex_skills(cwd: Path) -> None:
|
|
|
1170
1183
|
skill_md = skill_dir / "SKILL.md"
|
|
1171
1184
|
if not skill_md.is_file():
|
|
1172
1185
|
continue
|
|
1186
|
+
if skill_dir.name in user_names:
|
|
1187
|
+
skipped += 1
|
|
1188
|
+
continue
|
|
1173
1189
|
|
|
1174
1190
|
mode = sync_codex_skill(skill_dir, skills_dst)
|
|
1175
1191
|
if mode == "linked":
|
|
@@ -1179,7 +1195,7 @@ def _install_codex_skills(cwd: Path) -> None:
|
|
|
1179
1195
|
else:
|
|
1180
1196
|
skipped += 1
|
|
1181
1197
|
|
|
1182
|
-
cleanup_codex_skills(skills_dst, skills_src)
|
|
1198
|
+
cleanup_codex_skills(skills_dst, skills_src, user_names)
|
|
1183
1199
|
|
|
1184
1200
|
print(
|
|
1185
1201
|
f" Installed: {linked + adapted} skills to .agents/skills/"
|
|
@@ -1187,6 +1203,22 @@ def _install_codex_skills(cwd: Path) -> None:
|
|
|
1187
1203
|
)
|
|
1188
1204
|
|
|
1189
1205
|
|
|
1206
|
+
def _install_codex_agents(cwd: Path, *, config_root: Path | None = None) -> None:
|
|
1207
|
+
"""Generate native Codex custom-agent TOML files."""
|
|
1208
|
+
from generate_codex_agents import generate as gen_codex_agents
|
|
1209
|
+
|
|
1210
|
+
written, removed = gen_codex_agents(cwd, config_root=config_root)
|
|
1211
|
+
agents_dir = (
|
|
1212
|
+
config_root / "agents"
|
|
1213
|
+
if config_root is not None
|
|
1214
|
+
else Path(".codex/agents")
|
|
1215
|
+
)
|
|
1216
|
+
message = f" Created: {agents_dir}/ ({written} agents"
|
|
1217
|
+
if removed:
|
|
1218
|
+
message += f", {removed} stale removed"
|
|
1219
|
+
print(message + ")")
|
|
1220
|
+
|
|
1221
|
+
|
|
1190
1222
|
def _try_generator(module_name: str, *args, **kwargs) -> bool:
|
|
1191
1223
|
"""Import and invoke ``<module>.generate(...)``.
|
|
1192
1224
|
|
|
@@ -1235,18 +1267,37 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
|
|
|
1235
1267
|
emit_pointer = not _claude_skills_discoverable(cwd)
|
|
1236
1268
|
|
|
1237
1269
|
if "copilot" in eds:
|
|
1270
|
+
from generate_copilot import (
|
|
1271
|
+
generate as gen_copilot_dir,
|
|
1272
|
+
preflight_cleanup as preflight_copilot_cleanup,
|
|
1273
|
+
)
|
|
1274
|
+
if not add_copilot_dir:
|
|
1275
|
+
from generate_copilot_hooks import (
|
|
1276
|
+
preflight_cleanup as preflight_copilot_hook_cleanup,
|
|
1277
|
+
)
|
|
1278
|
+
preflight_copilot_cleanup(cwd)
|
|
1279
|
+
preflight_copilot_hook_cleanup(cwd)
|
|
1238
1280
|
inject_with_rules(
|
|
1239
1281
|
"generate-copilot.sh",
|
|
1240
1282
|
cwd / ".github" / "copilot-instructions.md",
|
|
1241
1283
|
rules_dir,
|
|
1242
1284
|
)
|
|
1243
|
-
_install_copilot_agents_md(cwd)
|
|
1244
|
-
#
|
|
1245
|
-
#
|
|
1285
|
+
_install_copilot_agents_md(cwd, rules_dir)
|
|
1286
|
+
# Agents and skills are the minimal Copilot surface. Standard and above
|
|
1287
|
+
# add path instructions, prompts, and native lifecycle hooks.
|
|
1288
|
+
gen_copilot_dir(
|
|
1289
|
+
cwd,
|
|
1290
|
+
language_modules=language_modules,
|
|
1291
|
+
rules_dir=rules_dir,
|
|
1292
|
+
emit_prompts=add_copilot_dir,
|
|
1293
|
+
emit_instructions=add_copilot_dir,
|
|
1294
|
+
cleanup_disabled=not add_copilot_dir,
|
|
1295
|
+
)
|
|
1246
1296
|
if add_copilot_dir:
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1297
|
+
_try_generator("generate_copilot_hooks", cwd)
|
|
1298
|
+
else:
|
|
1299
|
+
from generate_copilot_hooks import cleanup as cleanup_copilot_hooks
|
|
1300
|
+
cleanup_copilot_hooks(cwd)
|
|
1250
1301
|
|
|
1251
1302
|
if "cursor" in eds:
|
|
1252
1303
|
inject_with_rules(
|
|
@@ -1338,8 +1389,10 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
|
|
|
1338
1389
|
)
|
|
1339
1390
|
# .codex/hooks.json — Codex lifecycle hooks
|
|
1340
1391
|
from generate_codex_hooks import generate as gen_codex_hooks
|
|
1341
|
-
gen_codex_hooks(cwd)
|
|
1392
|
+
gen_codex_hooks(cwd, global_install=False)
|
|
1342
1393
|
print(" Created: .codex/hooks.json")
|
|
1394
|
+
# .codex/agents/ -- native Codex custom-agent definitions
|
|
1395
|
+
_install_codex_agents(cwd)
|
|
1343
1396
|
# .agents/skills/ — Codex discovery path for repo-local skills
|
|
1344
1397
|
_install_codex_skills(cwd)
|
|
1345
1398
|
# --codex-skills explicitly re-runs the same Codex skill sync path.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Render shared editor instructions from canonical ai-toolkit sources."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CONSTITUTION_PATH = (
|
|
9
|
+
Path(__file__).resolve().parent.parent / "app" / "constitution.md"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _strip_frontmatter(text: str) -> str:
|
|
14
|
+
"""Return Markdown after an optional leading YAML frontmatter block."""
|
|
15
|
+
lines = text.splitlines()
|
|
16
|
+
if not lines or lines[0] != "---":
|
|
17
|
+
return text.strip()
|
|
18
|
+
try:
|
|
19
|
+
closing = lines.index("---", 1)
|
|
20
|
+
except ValueError:
|
|
21
|
+
return text.strip()
|
|
22
|
+
return "\n".join(lines[closing + 1:]).strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_constitution(path: Path = CONSTITUTION_PATH) -> str:
|
|
26
|
+
"""Read the canonical constitution body without YAML frontmatter."""
|
|
27
|
+
return _strip_frontmatter(path.read_text(encoding="utf-8"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_constitution_policy(
|
|
31
|
+
heading_level: int = 2,
|
|
32
|
+
path: Path = CONSTITUTION_PATH,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Render the canonical constitution below a caller-selected heading."""
|
|
35
|
+
if not 1 <= heading_level <= 5:
|
|
36
|
+
raise ValueError("heading_level must be between 1 and 5")
|
|
37
|
+
|
|
38
|
+
body_lines = read_constitution(path).splitlines()
|
|
39
|
+
if body_lines and body_lines[0].startswith("# "):
|
|
40
|
+
body_lines = body_lines[1:]
|
|
41
|
+
while body_lines and not body_lines[0].strip():
|
|
42
|
+
body_lines.pop(0)
|
|
43
|
+
|
|
44
|
+
heading_shift = heading_level - 1
|
|
45
|
+
rebased: list[str] = []
|
|
46
|
+
for line in body_lines:
|
|
47
|
+
match = re.match(r"^(#{1,6})(\s+.*)$", line)
|
|
48
|
+
if not match:
|
|
49
|
+
rebased.append(line)
|
|
50
|
+
continue
|
|
51
|
+
level = min(6, len(match.group(1)) + heading_shift)
|
|
52
|
+
rebased.append("#" * level + match.group(2))
|
|
53
|
+
|
|
54
|
+
title = "#" * heading_level + " Constitution"
|
|
55
|
+
source = "Generated from `app/constitution.md`, the single policy source."
|
|
56
|
+
return f"{title}\n\n{source}\n\n" + "\n".join(rebased).rstrip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _demote_rule_heading(body: str) -> str:
|
|
60
|
+
if body.startswith("# "):
|
|
61
|
+
return "### " + body[2:]
|
|
62
|
+
return body
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_instruction_core() -> str:
|
|
66
|
+
"""Render the compact root AGENTS.md body shared by Codex and Copilot."""
|
|
67
|
+
from dir_rules_shared import (
|
|
68
|
+
rule_code_style,
|
|
69
|
+
rule_output_mode,
|
|
70
|
+
rule_security,
|
|
71
|
+
rule_testing,
|
|
72
|
+
)
|
|
73
|
+
from emission import generate_workflow_guidelines
|
|
74
|
+
|
|
75
|
+
coding_rules = "\n\n".join(
|
|
76
|
+
_demote_rule_heading(rule_fn().rstrip())
|
|
77
|
+
for rule_fn in (
|
|
78
|
+
rule_code_style,
|
|
79
|
+
rule_testing,
|
|
80
|
+
rule_security,
|
|
81
|
+
rule_output_mode,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
sections = [
|
|
85
|
+
"# AI Toolkit Instructions",
|
|
86
|
+
(
|
|
87
|
+
"Shared, always-on policy for ai-toolkit projects. Agent and skill"
|
|
88
|
+
" catalogs are discovered from their native directories instead of"
|
|
89
|
+
" being duplicated here."
|
|
90
|
+
),
|
|
91
|
+
render_constitution_policy(heading_level=2),
|
|
92
|
+
generate_workflow_guidelines(),
|
|
93
|
+
"## Coding Rules\n\n" + coding_rules,
|
|
94
|
+
]
|
|
95
|
+
return "\n\n".join(sections).rstrip() + "\n"
|