@softspark/ai-toolkit 2.4.0 → 2.5.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 (52) hide show
  1. package/AGENTS.md +32 -19
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +13 -12
  4. package/app/.claude-plugin/plugin.json +1 -1
  5. package/app/ARCHITECTURE.md +2 -2
  6. package/app/agents/code-reviewer.md +6 -7
  7. package/app/agents/frontend-specialist.md +33 -2
  8. package/app/agents/seo-specialist.md +1 -1
  9. package/app/personas/frontend-lead.md +48 -5
  10. package/app/skills/a11y-validate/SKILL.md +377 -0
  11. package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
  12. package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
  13. package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
  14. package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
  15. package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
  16. package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
  17. package/app/skills/clean-code/reference/python.md +3 -3
  18. package/app/skills/design-engineering/SKILL.md +2 -5
  19. package/app/skills/review/SKILL.md +30 -6
  20. package/app/skills/seo-validate/SKILL.md +460 -0
  21. package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
  22. package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
  23. package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
  24. package/app/skills/seo-validate/reference/schema-types.md +465 -0
  25. package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
  26. package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
  27. package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
  28. package/bin/ai-toolkit.js +32 -5
  29. package/kb/reference/architecture-overview.md +3 -3
  30. package/kb/reference/cli-reference.md +1 -1
  31. package/kb/reference/codex-cli-compatibility.md +4 -0
  32. package/kb/reference/comparison.md +1 -1
  33. package/kb/reference/extension-api.md +2 -0
  34. package/kb/reference/skills-catalog.md +3 -1
  35. package/llms-full.txt +16 -6
  36. package/manifest.json +3 -3
  37. package/package.json +2 -2
  38. package/scripts/config_cli.py +4 -10
  39. package/scripts/config_resolver.py +23 -5
  40. package/scripts/doctor.py +76 -4
  41. package/scripts/hook_sources.py +3 -0
  42. package/scripts/inject_hook_cli.py +74 -1
  43. package/scripts/install.py +34 -3
  44. package/scripts/install_steps/ai_tools.py +79 -16
  45. package/scripts/install_steps/install_state.py +25 -0
  46. package/scripts/install_steps/markers.py +2 -1
  47. package/scripts/install_steps/project_registry.py +9 -0
  48. package/scripts/plugin.py +1 -1
  49. package/scripts/propagate_global.py +92 -0
  50. package/scripts/rule_sources.py +3 -2
  51. package/scripts/update_projects.py +7 -1
  52. package/scripts/url_fetch.py +5 -0
@@ -19,50 +19,113 @@ from injection import (
19
19
 
20
20
 
21
21
  def install_ai_tools(target_dir: Path, rules_dir: Path,
22
- only: str, skip: str, dry_run: bool) -> None:
23
- """Install Cursor, Windsurf, Gemini global configs."""
22
+ dry_run: bool,
23
+ editors: list[str] | None = None) -> list[str]:
24
+ """Install global editor configs.
25
+
26
+ Args:
27
+ editors: Explicit list of editors to install globally. If None,
28
+ uses DEFAULT_GLOBAL_EDITORS (empty = Claude only).
29
+
30
+ Returns:
31
+ List of editors that were actually installed (for state tracking).
32
+ """
33
+ from install_steps.install_state import DEFAULT_GLOBAL_EDITORS, GLOBAL_CAPABLE_EDITORS
34
+
35
+ if editors is None:
36
+ eds = set(DEFAULT_GLOBAL_EDITORS)
37
+ else:
38
+ eds = set(editors)
39
+
40
+ # Filter to only globally-capable editors
41
+ eds = eds & set(GLOBAL_CAPABLE_EDITORS)
42
+
43
+ if not eds:
44
+ return []
45
+
24
46
  print()
25
47
  print("## Other AI Tools (global)")
26
48
  print()
27
49
 
28
- if should_install("cursor", only, skip):
50
+ installed: list[str] = []
51
+
52
+ # Editors are opt-in via --editors, not filtered by --only/--skip (those
53
+ # control Claude components like agents, hooks, rules). If an editor is
54
+ # in the requested set, install it unconditionally.
55
+
56
+ if "cursor" in eds:
29
57
  cursor_file = target_dir / ".cursor" / "rules"
30
58
  if dry_run:
31
59
  print(" Would inject: ~/.cursor/rules")
32
60
  else:
33
61
  inject_with_rules("generate-cursor-rules.sh", cursor_file, rules_dir)
34
- else:
35
- print(" Skipped: cursor")
62
+ installed.append("cursor")
36
63
 
37
- if should_install("windsurf", only, skip):
64
+ if "windsurf" in eds:
38
65
  windsurf_file = target_dir / ".codeium" / "windsurf" / "memories" / "global_rules.md"
39
66
  if dry_run:
40
67
  print(" Would inject: ~/.codeium/windsurf/memories/global_rules.md")
41
68
  else:
42
69
  inject_with_rules("generate-windsurf.sh", windsurf_file, rules_dir)
43
- else:
44
- print(" Skipped: windsurf")
70
+ installed.append("windsurf")
45
71
 
46
- if should_install("gemini", only, skip):
72
+ if "gemini" in eds:
47
73
  gemini_file = target_dir / ".gemini" / "GEMINI.md"
48
74
  if dry_run:
49
75
  print(" Would inject: ~/.gemini/GEMINI.md")
50
76
  else:
51
77
  inject_with_rules("generate-gemini.sh", gemini_file, rules_dir)
52
- else:
53
- print(" Skipped: gemini")
78
+ installed.append("gemini")
54
79
 
55
- if should_install("augment", only, skip):
80
+ if "augment" in eds:
56
81
  augment_file = target_dir / ".augment" / "rules" / "ai-toolkit.md"
57
82
  if dry_run:
58
83
  print(" Would inject: ~/.augment/rules/ai-toolkit.md")
59
84
  else:
60
85
  inject_with_rules("generate-augment.sh", augment_file, rules_dir)
61
- else:
62
- print(" Skipped: augment")
86
+ installed.append("augment")
87
+
88
+ if "codex" in eds:
89
+ if dry_run:
90
+ print(" Would inject: ~/AGENTS.md, ~/.agents/, ~/.codex/hooks.json")
91
+ else:
92
+ _install_codex_global(target_dir, rules_dir)
93
+ installed.append("codex")
63
94
 
64
95
  print()
65
- print(" Note: Copilot, Cline, Roo Code, and Aider have no global config -- use 'ai-toolkit install --local' per project")
96
+ print(f" Available: {', '.join(GLOBAL_CAPABLE_EDITORS)}")
97
+ print(" Note: Copilot, Cline, Roo Code, Aider, Antigravity have no global config -- use 'ai-toolkit install --local' per project")
98
+
99
+ return installed
100
+
101
+
102
+ def _install_codex_global(target_dir: Path, rules_dir: Path) -> None:
103
+ """Install Codex at the global level (~/ layer).
104
+
105
+ Creates:
106
+ - ~/AGENTS.md (marker injection with rules)
107
+ - ~/.agents/rules/*.md (directory-based rules)
108
+ - ~/.agents/skills/* (skill symlinks)
109
+ - ~/.codex/hooks.json (lifecycle hooks)
110
+ """
111
+ inject_with_rules(
112
+ "generate_codex.py",
113
+ target_dir / "AGENTS.md",
114
+ rules_dir,
115
+ )
116
+
117
+ from generate_codex_rules import generate as gen_codex_rules
118
+ gen_codex_rules(
119
+ target_dir,
120
+ rules_dir=rules_dir,
121
+ managed_scopes=("standard", "custom"),
122
+ )
123
+
124
+ from generate_codex_hooks import generate as gen_codex_hooks
125
+ gen_codex_hooks(target_dir)
126
+ print(" Created: ~/.codex/hooks.json")
127
+
128
+ _install_codex_skills(target_dir)
66
129
 
67
130
 
68
131
  def inject_with_rules(
@@ -131,7 +194,7 @@ def run_script(script_name: str, *args: str, capture: bool = False) -> str:
131
194
  # All known editor identifiers for --editors flag
132
195
  ALL_EDITORS = [
133
196
  "copilot", "cursor", "windsurf", "cline", "roo",
134
- "aider", "augment", "antigravity", "codex",
197
+ "aider", "augment", "antigravity", "codex", "gemini",
135
198
  ]
136
199
 
137
200
  # Map of project files/dirs → editor names for auto-detection
@@ -85,6 +85,27 @@ def remove_mcp_template(name: str) -> None:
85
85
  save_state(state)
86
86
 
87
87
 
88
+ # Default global install: Claude only — no other editors unless --editors is used
89
+ DEFAULT_GLOBAL_EDITORS: list[str] = []
90
+
91
+ # All editors that support global install (opt-in via --editors)
92
+ GLOBAL_CAPABLE_EDITORS = ["augment", "codex", "cursor", "gemini", "windsurf"]
93
+
94
+
95
+ def get_global_editors() -> list[str]:
96
+ """Return list of globally installed editor names from state."""
97
+ state = load_state()
98
+ editors = state.get("global_editors", [])
99
+ return editors if isinstance(editors, list) else []
100
+
101
+
102
+ def record_global_editors(editors: list[str]) -> None:
103
+ """Record which editors are installed globally in state.json."""
104
+ state = load_state()
105
+ state["global_editors"] = sorted(set(editors))
106
+ save_state(state)
107
+
108
+
88
109
  def _now_iso() -> str:
89
110
  """Return current UTC time in ISO 8601 format."""
90
111
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -163,6 +184,10 @@ def print_status() -> None:
163
184
  langs = [m.replace("rules-", "") for m in detected]
164
185
  print(f" Detected: {', '.join(langs)}")
165
186
 
187
+ editors = state.get("global_editors", [])
188
+ if editors:
189
+ print(f" Editors: {', '.join(editors)}")
190
+
166
191
  mcp = state.get("mcp_templates", [])
167
192
  if mcp:
168
193
  print(f" MCP: {', '.join(mcp)}")
@@ -105,7 +105,7 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
105
105
  Called during ``ai-toolkit update`` to keep URL-sourced hooks current.
106
106
  On fetch failure, warns and keeps the cached version.
107
107
  """
108
- from hook_sources import get_url_hooks
108
+ from hook_sources import get_url_hooks, register_url_source
109
109
  from paths import EXTERNAL_HOOKS_DIR
110
110
  from url_fetch import fetch_url
111
111
  import json
@@ -124,6 +124,7 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
124
124
  # Validate JSON before caching
125
125
  json.loads(data)
126
126
  cached_file.write_bytes(data)
127
+ register_url_source(None, hook_name, url)
127
128
  print(f" Refreshed: {hook_name} (from {url})")
128
129
  except Exception as exc:
129
130
  if cached_file.is_file():
@@ -130,11 +130,17 @@ def register_project(
130
130
  project_path: str | Path,
131
131
  profile: str = "",
132
132
  extends: str = "",
133
+ editors: list[str] | None = None,
133
134
  ) -> bool:
134
135
  """Register a project directory. Returns True if newly added, False if updated.
135
136
 
136
137
  Idempotent — updates existing entry if path already registered.
137
138
  Uses file lock to prevent concurrent read-modify-write races.
139
+
140
+ Args:
141
+ editors: List of editors installed locally (e.g. ["codex", "cursor"]).
142
+ If provided, replaces the stored editors list. If None, keeps
143
+ existing editors (or empty for new projects).
138
144
  """
139
145
  project_path = str(Path(project_path).resolve())
140
146
 
@@ -153,6 +159,8 @@ def register_project(
153
159
  elif "extends" in p and not extends:
154
160
  # Clear extends if project no longer uses it
155
161
  pass
162
+ if editors is not None:
163
+ p["editors"] = sorted(set(editors))
156
164
  save_registry(projects)
157
165
  return False
158
166
 
@@ -163,6 +171,7 @@ def register_project(
163
171
  "last_updated": now,
164
172
  "profile": profile or "standard",
165
173
  "extends": extends or "",
174
+ "editors": sorted(set(editors)) if editors else [],
166
175
  })
167
176
  save_registry(projects)
168
177
  return True
package/scripts/plugin.py CHANGED
@@ -912,7 +912,7 @@ def cmd_status(editors: list[str]) -> None:
912
912
  print(f" Hooks: {', '.join(h.name for h in hooks)}")
913
913
  elif editor == "codex":
914
914
  rules_dir = CODEX_ROOT / ".agents" / "rules"
915
- rule_files = sorted(rules_dir.glob(f"ai-toolkit-plugin-{name}-*.md")) if rules_dir.is_dir() else []
915
+ rule_files = sorted(rules_dir.glob(f"plugin-{name}-*.md")) if rules_dir.is_dir() else []
916
916
  if rule_files:
917
917
  print(f" Rules: {', '.join(f.name for f in rule_files)}")
918
918
  if name == "memory-pack":
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+ """Propagate rules, hooks, and MCP configs to all globally installed editors.
3
+
4
+ Called automatically after inject-rule, inject-hook, add-rule, remove-rule,
5
+ and mcp add to keep global editor configs in sync.
6
+
7
+ Usage:
8
+ propagate_global.py [--rules] [--hooks] [--mcp]
9
+
10
+ Flags (can combine):
11
+ --rules Re-inject registered rules into global editor configs
12
+ --hooks Re-inject external hooks into Codex global hooks.json
13
+ --mcp Sync MCP templates to global editor MCP configs
14
+
15
+ With no flags, propagates rules (the most common case).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+
25
+ def propagate_rules() -> None:
26
+ """Re-inject registered rules into all global editors from state."""
27
+ from paths import RULES_DIR
28
+ from install_steps.install_state import get_global_editors
29
+
30
+ editors = get_global_editors()
31
+ if not editors:
32
+ return
33
+
34
+ target_dir = Path.home()
35
+ rules_dir = RULES_DIR
36
+
37
+ from install_steps.ai_tools import install_ai_tools
38
+ print("Propagating rules to global editors...")
39
+ install_ai_tools(target_dir, rules_dir, dry_run=False, editors=editors)
40
+
41
+
42
+ def propagate_hooks() -> None:
43
+ """Re-inject URL-sourced hooks into Codex global hooks.json."""
44
+ from install_steps.install_state import get_global_editors
45
+
46
+ editors = get_global_editors()
47
+ if "codex" not in editors:
48
+ return
49
+
50
+ # Hooks are already propagated to Codex by inject_hook_cli.py
51
+ # This is a no-op — kept for completeness and future editors with hooks
52
+ pass
53
+
54
+
55
+ def propagate_mcp() -> None:
56
+ """Sync globally tracked MCP templates to global editor MCP configs."""
57
+ from install_steps.install_state import get_global_editors, get_mcp_templates
58
+
59
+ editors = get_global_editors()
60
+ templates = get_mcp_templates()
61
+ if not editors or not templates:
62
+ return
63
+
64
+ # MCP editor sync is handled by mcp_manager.py install --editor --scope global
65
+ import subprocess
66
+ scripts_dir = Path(__file__).resolve().parent
67
+
68
+ for editor in editors:
69
+ try:
70
+ subprocess.run(
71
+ ["python3", str(scripts_dir / "mcp_manager.py"),
72
+ "install", "--editor", editor, "--scope", "global"] + templates,
73
+ capture_output=True, text=True, timeout=30,
74
+ )
75
+ print(f" MCP synced to {editor} (global)")
76
+ except Exception as exc:
77
+ print(f" Warning: MCP sync to {editor} failed: {exc}")
78
+
79
+
80
+ def main() -> None:
81
+ args = set(sys.argv[1:])
82
+
83
+ if not args or "--rules" in args:
84
+ propagate_rules()
85
+ if "--hooks" in args:
86
+ propagate_hooks()
87
+ if "--mcp" in args:
88
+ propagate_mcp()
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
@@ -23,8 +23,6 @@ from paths import RULES_DIR
23
23
  from url_fetch import fetch_url as fetch_url # noqa: F811 — re-export
24
24
 
25
25
  _SOURCES_FILENAME = "sources.json"
26
- _FETCH_TIMEOUT = 30 # seconds
27
- _FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10MB
28
26
 
29
27
 
30
28
  # ---------------------------------------------------------------------------
@@ -83,6 +81,9 @@ def save_sources(rules_dir: Path | None = None,
83
81
 
84
82
  def register_url_source(rules_dir: Path | None, rule_name: str, url: str) -> None:
85
83
  """Add or update a URL source entry."""
84
+ import re
85
+ if not rule_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", rule_name):
86
+ raise ValueError(f"Invalid rule name: {rule_name!r}")
86
87
  rules_dir = rules_dir or RULES_DIR
87
88
  sources = load_sources(rules_dir)
88
89
  sources[rule_name] = {
@@ -25,9 +25,15 @@ def _update_project(project: dict[str, Any], install_script: str, extra_args: li
25
25
  project_path = project["path"]
26
26
  start = time.monotonic()
27
27
 
28
+ # Pass saved editors from registry so update re-installs the same editors
29
+ cmd_args = ["python3", install_script, "--local"] + extra_args
30
+ project_editors = project.get("editors", [])
31
+ if project_editors:
32
+ cmd_args.extend(["--editors", ",".join(project_editors)])
33
+
28
34
  try:
29
35
  proc = subprocess.run(
30
- ["python3", install_script, "--local"] + extra_args,
36
+ cmd_args,
31
37
  cwd=project_path,
32
38
  capture_output=True,
33
39
  text=True,
@@ -37,6 +37,11 @@ def fetch_url(url: str) -> bytes:
37
37
  ctx = ssl.create_default_context()
38
38
  with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT, context=ctx) as resp:
39
39
  data = resp.read(_FETCH_MAX_BYTES)
40
+ # Detect truncation — if there's more data, the response exceeds the limit
41
+ if resp.read(1):
42
+ raise ValueError(
43
+ f"Response exceeds {_FETCH_MAX_BYTES} byte limit: {url}"
44
+ )
40
45
 
41
46
  # Basic binary detection — reject if null bytes present
42
47
  if b"\x00" in data: