@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.
- package/AGENTS.md +32 -19
- package/CHANGELOG.md +45 -0
- package/README.md +13 -12
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/agents/code-reviewer.md +6 -7
- package/app/agents/frontend-specialist.md +33 -2
- package/app/agents/seo-specialist.md +1 -1
- package/app/personas/frontend-lead.md +48 -5
- package/app/skills/a11y-validate/SKILL.md +377 -0
- package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
- package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
- package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
- package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
- package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
- package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
- package/app/skills/clean-code/reference/python.md +3 -3
- package/app/skills/design-engineering/SKILL.md +2 -5
- package/app/skills/review/SKILL.md +30 -6
- package/app/skills/seo-validate/SKILL.md +460 -0
- package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
- package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
- package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
- package/app/skills/seo-validate/reference/schema-types.md +465 -0
- package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
- package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
- package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
- package/bin/ai-toolkit.js +32 -5
- package/kb/reference/architecture-overview.md +3 -3
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/codex-cli-compatibility.md +4 -0
- package/kb/reference/comparison.md +1 -1
- package/kb/reference/extension-api.md +2 -0
- package/kb/reference/skills-catalog.md +3 -1
- package/llms-full.txt +16 -6
- package/manifest.json +3 -3
- package/package.json +2 -2
- package/scripts/config_cli.py +4 -10
- package/scripts/config_resolver.py +23 -5
- package/scripts/doctor.py +76 -4
- package/scripts/hook_sources.py +3 -0
- package/scripts/inject_hook_cli.py +74 -1
- package/scripts/install.py +34 -3
- package/scripts/install_steps/ai_tools.py +79 -16
- package/scripts/install_steps/install_state.py +25 -0
- package/scripts/install_steps/markers.py +2 -1
- package/scripts/install_steps/project_registry.py +9 -0
- package/scripts/plugin.py +1 -1
- package/scripts/propagate_global.py +92 -0
- package/scripts/rule_sources.py +3 -2
- package/scripts/update_projects.py +7 -1
- 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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
print(" Skipped: cursor")
|
|
62
|
+
installed.append("cursor")
|
|
36
63
|
|
|
37
|
-
if
|
|
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
|
-
|
|
44
|
-
print(" Skipped: windsurf")
|
|
70
|
+
installed.append("windsurf")
|
|
45
71
|
|
|
46
|
-
if
|
|
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
|
-
|
|
53
|
-
print(" Skipped: gemini")
|
|
78
|
+
installed.append("gemini")
|
|
54
79
|
|
|
55
|
-
if
|
|
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
|
-
|
|
62
|
-
|
|
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("
|
|
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"
|
|
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()
|
package/scripts/rule_sources.py
CHANGED
|
@@ -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
|
-
|
|
36
|
+
cmd_args,
|
|
31
37
|
cwd=project_path,
|
|
32
38
|
capture_output=True,
|
|
33
39
|
text=True,
|
package/scripts/url_fetch.py
CHANGED
|
@@ -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:
|