@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +11 -10
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/CLAUDE.md.template +3 -0
  5. package/app/agents/fact-checker.md +1 -1
  6. package/app/hooks/_search-capability.sh +3 -2
  7. package/app/hooks/stop-search-check.sh +2 -1
  8. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  9. package/kb/procedures/maintenance-sop.md +26 -13
  10. package/kb/procedures/release-verification-sop.md +41 -36
  11. package/kb/reference/architecture-overview.md +23 -7
  12. package/kb/reference/codex-cli-compatibility.md +96 -36
  13. package/kb/reference/extension-api.md +52 -9
  14. package/kb/reference/global-install-model.md +53 -21
  15. package/kb/reference/hooks-catalog.md +44 -8
  16. package/kb/reference/mcp-editor-compatibility.md +27 -6
  17. package/kb/reference/mcp-templates.md +12 -6
  18. package/kb/reference/opencode-compatibility.md +13 -7
  19. package/kb/reference/plugin-pack-conventions.md +7 -7
  20. package/kb/reference/skills-catalog.md +3 -3
  21. package/kb/reference/supported-tools-registry.md +19 -17
  22. package/kb/reference/windows-support.md +26 -3
  23. package/llms-full.txt +443 -180
  24. package/llms.txt +1 -1
  25. package/manifest.json +1 -1
  26. package/package.json +2 -2
  27. package/scripts/codex_skill_adapter.py +448 -198
  28. package/scripts/dir_rules_shared.py +2 -11
  29. package/scripts/ecosystem_tools.json +29 -8
  30. package/scripts/emission.py +5 -91
  31. package/scripts/generate_agents_md.py +4 -87
  32. package/scripts/generate_codex.py +5 -95
  33. package/scripts/generate_codex_agents.py +242 -0
  34. package/scripts/generate_codex_hooks.py +648 -55
  35. package/scripts/generate_codex_skills.py +15 -6
  36. package/scripts/generate_copilot.py +771 -74
  37. package/scripts/generate_copilot_hooks.py +606 -0
  38. package/scripts/generate_cursor_hooks.py +453 -121
  39. package/scripts/generate_opencode_commands.py +4 -6
  40. package/scripts/inject_hook_cli.py +770 -205
  41. package/scripts/injection.py +102 -23
  42. package/scripts/install_steps/ai_tools.py +123 -83
  43. package/scripts/instruction_core.py +95 -0
  44. package/scripts/mcp_editors.py +934 -80
  45. package/scripts/mcp_manager.py +46 -26
  46. package/scripts/plugin.py +291 -114
  47. package/scripts/secure_fs.py +538 -0
  48. package/scripts/uninstall.py +1279 -208
@@ -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
- start = f"<!-- TOOLKIT:{section} START -->"
42
- end = f"<!-- TOOLKIT:{section} END -->"
43
- lines: list[str] = []
44
- skip = False
45
- for line in content.splitlines(keepends=True):
46
- stripped = line.rstrip("\n")
47
- if stripped == start:
48
- skip = True
49
- continue
50
- if stripped == end:
51
- skip = False
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
- if not skip:
54
- lines.append(line)
55
- return "".join(lines)
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
- # Sanitize section name
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 existing section
116
- existing = strip_section(existing, section)
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(f"<!-- TOOLKIT:{section} START -->")
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(f"<!-- TOOLKIT:{section} END -->")
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 = re.sub(r"[^a-zA-Z0-9_-]", "", rule_file.stem)
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
- start_marker = f"<!-- TOOLKIT:{rule_name} START -->"
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, should_install, toolkit_dir
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
- strip_section as _strip_section,
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: ~/.codex/AGENTS.md, ~/.agents/skills/, ~/.codex/hooks.json")
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 user-level instructions from ~/.copilot/. RULES have
170
- # a documented global surface here even though .github/ stays repo-only.
171
- copilot_root = target_dir / ".copilot"
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: ~/.copilot/copilot-instructions.md")
174
- print(" Would generate: ~/.copilot/instructions/ai-toolkit-*.instructions.md")
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 global level (~/.codex layer).
242
+ """Install Codex at the active user-level ``CODEX_HOME`` layer.
208
243
 
209
244
  Creates:
210
- - ~/.codex/AGENTS.md (marker injection; universal coding rules inlined
211
- here). Codex reads GLOBAL instructions from ``$CODEX_HOME/AGENTS.md``
212
- (default ~/.codex/AGENTS.md), NOT ~/AGENTS.md a home-root AGENTS.md
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
- target_dir / ".codex" / "AGENTS.md",
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 = target_dir / ".codex" / "AGENTS.override.md"
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: ~/.codex/AGENTS.override.md exists and takes precedence "
233
- "over ~/.codex/AGENTS.md — toolkit rules will be masked."
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(target_dir)
238
- print(" Created: ~/.codex/hooks.json")
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
- import re
511
- existing = re.sub(
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 = re.sub(
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 _inject_text_section(target_file: Path, section: str, text: str) -> None:
563
- """Inject generated text into one marker section without touching others."""
564
- target_file.parent.mkdir(parents=True, exist_ok=True)
565
- existing = target_file.read_text(encoding="utf-8") if target_file.is_file() else ""
566
- existing = _trim_trailing_blanks(_strip_section(existing, section))
567
-
568
- parts: list[str] = []
569
- if existing.strip():
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(f" Would apply merged config from extends")
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(f" Saved: .softspark-toolkit-extends.json (resolution metadata)")
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"`{l}-rules`" for l in langs)
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 and add_copilot_dir:
1035
- print(" Would generate: .github/instructions/ + .github/prompts/ (profile >= standard)")
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 / ".agents" / "skills"
1162
- skills_dst.mkdir(parents=True, exist_ok=True)
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
 
@@ -1240,13 +1272,19 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
1240
1272
  cwd / ".github" / "copilot-instructions.md",
1241
1273
  rules_dir,
1242
1274
  )
1243
- _install_copilot_agents_md(cwd)
1244
- # `standard` and above: emit path-specific instructions + prompt files
1245
- # (directory mode). `minimal` stays backwards-compatible with v2.
1275
+ _install_copilot_agents_md(cwd, rules_dir)
1276
+ # Agents and skills are the minimal Copilot surface. Standard and above
1277
+ # add path instructions, prompts, and native lifecycle hooks.
1278
+ from generate_copilot import generate as gen_copilot_dir
1279
+ gen_copilot_dir(
1280
+ cwd,
1281
+ language_modules=language_modules,
1282
+ rules_dir=rules_dir,
1283
+ emit_prompts=add_copilot_dir,
1284
+ emit_instructions=add_copilot_dir,
1285
+ )
1246
1286
  if add_copilot_dir:
1247
- from generate_copilot import generate as gen_copilot_dir
1248
- gen_copilot_dir(cwd, language_modules=language_modules,
1249
- rules_dir=rules_dir)
1287
+ _try_generator("generate_copilot_hooks", cwd)
1250
1288
 
1251
1289
  if "cursor" in eds:
1252
1290
  inject_with_rules(
@@ -1338,8 +1376,10 @@ def _create_local_ai_tool_configs(cwd: Path, rules_dir: Path,
1338
1376
  )
1339
1377
  # .codex/hooks.json — Codex lifecycle hooks
1340
1378
  from generate_codex_hooks import generate as gen_codex_hooks
1341
- gen_codex_hooks(cwd)
1379
+ gen_codex_hooks(cwd, global_install=False)
1342
1380
  print(" Created: .codex/hooks.json")
1381
+ # .codex/agents/ -- native Codex custom-agent definitions
1382
+ _install_codex_agents(cwd)
1343
1383
  # .agents/skills/ — Codex discovery path for repo-local skills
1344
1384
  _install_codex_skills(cwd)
1345
1385
  # --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"