@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
@@ -9,6 +9,7 @@ Usage:
9
9
  mcp_manager.py install --editor <name[,..]> [--scope project|global] [--target <path>] [name..]
10
10
  mcp_manager.py remove <name> Remove from .mcp.json
11
11
  """
12
+
12
13
  from __future__ import annotations
13
14
 
14
15
  import json
@@ -18,9 +19,11 @@ from pathlib import Path
18
19
  from mcp_editors import (
19
20
  editor_rows,
20
21
  install_servers,
22
+ load_json_config,
21
23
  load_project_mcp_servers,
22
24
  remove_servers,
23
25
  supported_editors,
26
+ write_json_config,
24
27
  )
25
28
 
26
29
  TOOLKIT_DIR = Path(__file__).resolve().parent.parent
@@ -32,12 +35,13 @@ MCP_CONFIG_NAME = ".mcp.json"
32
35
  # Helpers
33
36
  # ---------------------------------------------------------------------------
34
37
 
38
+
35
39
  def load_template(name: str) -> dict:
36
40
  """Load a template JSON by name. Exits on error."""
37
41
  template_path = TEMPLATES_DIR / f"{name}.json"
38
42
  if not template_path.is_file():
39
43
  print(f"Error: template '{name}' not found at {template_path}", file=sys.stderr)
40
- print(f"Run 'ai-toolkit mcp list' to see available templates.", file=sys.stderr)
44
+ print("Run 'ai-toolkit mcp list' to see available templates.", file=sys.stderr)
41
45
  sys.exit(1)
42
46
  with open(template_path, encoding="utf-8") as f:
43
47
  return json.load(f)
@@ -49,29 +53,29 @@ def available_templates() -> list[dict]:
49
53
  for p in sorted(TEMPLATES_DIR.glob("*.json")):
50
54
  with open(p, encoding="utf-8") as f:
51
55
  data = json.load(f)
52
- templates.append({
53
- "name": data.get("name", p.stem),
54
- "description": data.get("description", ""),
55
- "file": p.name,
56
- })
56
+ templates.append(
57
+ {
58
+ "name": data.get("name", p.stem),
59
+ "description": data.get("description", ""),
60
+ "file": p.name,
61
+ }
62
+ )
57
63
  return templates
58
64
 
59
65
 
60
66
  def load_mcp_config(target_dir: Path) -> dict:
61
67
  """Load existing .mcp.json or return empty structure."""
62
68
  config_path = target_dir / MCP_CONFIG_NAME
63
- if config_path.is_file():
64
- with open(config_path, encoding="utf-8") as f:
65
- return json.load(f)
69
+ data = load_json_config(config_path)
70
+ if config_path.exists():
71
+ return data
66
72
  return {"mcpServers": {}}
67
73
 
68
74
 
69
75
  def write_mcp_config(target_dir: Path, config: dict) -> None:
70
76
  """Write .mcp.json with pretty formatting."""
71
77
  config_path = target_dir / MCP_CONFIG_NAME
72
- with open(config_path, "w", encoding="utf-8") as f:
73
- json.dump(config, f, indent=2)
74
- f.write("\n")
78
+ write_json_config(config_path, config)
75
79
  print(f"Updated: {config_path}")
76
80
 
77
81
 
@@ -79,6 +83,7 @@ def write_mcp_config(target_dir: Path, config: dict) -> None:
79
83
  # Commands
80
84
  # ---------------------------------------------------------------------------
81
85
 
86
+
82
87
  def cmd_list() -> None:
83
88
  """List all available MCP templates."""
84
89
  templates = available_templates()
@@ -92,7 +97,7 @@ def cmd_list() -> None:
92
97
  print(f"{t['name']:<25} {t['description']}")
93
98
  print()
94
99
  print(f"{len(templates)} templates available")
95
- print(f"Add with: ai-toolkit mcp add <name>")
100
+ print("Add with: ai-toolkit mcp add <name>")
96
101
 
97
102
 
98
103
  def cmd_editors() -> None:
@@ -184,18 +189,24 @@ def cmd_install(
184
189
  )
185
190
  sys.exit(1)
186
191
 
187
- eff_scope = scope or ("project" if target_dir is not None or not names else "global")
192
+ eff_scope = scope or (
193
+ "project" if target_dir is not None or not names else "global"
194
+ )
188
195
  if eff_scope == "project":
189
196
  project_dir = target_dir or Path.cwd()
190
197
  if names:
191
- cmd_add(names, project_dir)
192
198
  servers = {}
193
199
  for name in names:
194
200
  servers.update(load_template(name).get("mcpServers", {}))
201
+ # `.mcp.json` is the canonical Claude project config. Include it
202
+ # in the same transaction as every requested native editor so a
203
+ # preflight or late write failure cannot leave partial state.
204
+ transaction_editors = list(dict.fromkeys(["claude", *editors]))
195
205
  else:
196
206
  servers = load_project_mcp_servers(project_dir)
207
+ transaction_editors = editors
197
208
  updated = install_servers(
198
- editors,
209
+ transaction_editors,
199
210
  servers,
200
211
  scope="project",
201
212
  project_dir=project_dir,
@@ -218,6 +229,7 @@ def cmd_install(
218
229
  # Track globally installed templates in state.json
219
230
  if eff_scope == "global" and names:
220
231
  from install_steps.install_state import record_mcp_template
232
+
221
233
  for name in names:
222
234
  record_mcp_template(name)
223
235
 
@@ -230,19 +242,17 @@ def cmd_install(
230
242
  print(f"\n Note ({name}): {post_install}")
231
243
 
232
244
 
233
- def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope: str | None) -> None:
245
+ def cmd_remove(
246
+ name: str, target_dir: Path | None, *, editors: list[str], scope: str | None
247
+ ) -> None:
234
248
  """Remove an MCP server from .mcp.json."""
235
249
  if editors:
236
250
  eff_scope = scope or ("project" if target_dir else "global")
237
251
  if eff_scope == "project":
238
252
  project_dir = target_dir or Path.cwd()
239
- config_path = project_dir / MCP_CONFIG_NAME
240
- if config_path.is_file():
241
- config = load_mcp_config(project_dir)
242
- config.get("mcpServers", {}).pop(name, None)
243
- write_mcp_config(project_dir, config)
253
+ transaction_editors = list(dict.fromkeys(["claude", *editors]))
244
254
  updated = remove_servers(
245
- editors,
255
+ transaction_editors,
246
256
  [name],
247
257
  scope="project",
248
258
  project_dir=project_dir,
@@ -251,6 +261,7 @@ def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope:
251
261
  updated = remove_servers(editors, [name], scope="global")
252
262
  # Untrack globally removed template from state.json
253
263
  from install_steps.install_state import remove_mcp_template
264
+
254
265
  remove_mcp_template(name)
255
266
  for path in updated:
256
267
  print(f"Updated: {path}")
@@ -267,7 +278,10 @@ def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope:
267
278
 
268
279
  if name not in servers:
269
280
  print(f"Error: server '{name}' not found in {config_path}.", file=sys.stderr)
270
- print(f"Available servers: {', '.join(servers.keys()) or '(none)'}", file=sys.stderr)
281
+ print(
282
+ f"Available servers: {', '.join(servers.keys()) or '(none)'}",
283
+ file=sys.stderr,
284
+ )
271
285
  sys.exit(1)
272
286
 
273
287
  del servers[name]
@@ -279,7 +293,10 @@ def cmd_remove(name: str, target_dir: Path | None, *, editors: list[str], scope:
279
293
  # Argument parsing
280
294
  # ---------------------------------------------------------------------------
281
295
 
282
- def parse_options(args: list[str]) -> tuple[list[str], Path | None, list[str], str | None]:
296
+
297
+ def parse_options(
298
+ args: list[str],
299
+ ) -> tuple[list[str], Path | None, list[str], str | None]:
283
300
  """Extract common MCP CLI options."""
284
301
  target_dir: Path | None = None
285
302
  editors: list[str] = []
@@ -288,7 +305,10 @@ def parse_options(args: list[str]) -> tuple[list[str], Path | None, list[str], s
288
305
  i = 0
289
306
  while i < len(args):
290
307
  if args[i] == "--target" and i + 1 < len(args):
291
- target_dir = Path(args[i + 1]).resolve()
308
+ # Keep symlink provenance so native writers can reject redirected
309
+ # project roots instead of silently resolving and writing through
310
+ # them. `absolute()` normalizes the cwd without dereferencing.
311
+ target_dir = Path(args[i + 1]).expanduser().absolute()
292
312
  i += 2
293
313
  elif args[i] == "--editor" and i + 1 < len(args):
294
314
  editors = [e.strip() for e in args[i + 1].split(",") if e.strip()]