@softspark/ai-toolkit 4.31.0 → 4.32.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/CHANGELOG.md +89 -0
- package/README.md +26 -19
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/claude-app/hooks/hooks.json +4 -2
- package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +30 -16
- package/app/hooks/quality-gate.sh +9 -2
- package/app/hooks.json +4 -2
- package/app/rules/common/git-team.md +33 -0
- package/app/rules/common/git-workflow.md +6 -20
- package/app/rules/common/performance.md +25 -1
- package/app/rules/common/testing.md +7 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
- package/bin/ai-toolkit.js +2 -0
- package/kb/procedures/sop-maintenance.md +6 -3
- package/kb/reference/cli-reference.md +3 -2
- package/kb/reference/global-install-model.md +16 -3
- package/kb/reference/hooks-catalog.md +5 -3
- package/kb/reference/language-rules.md +28 -10
- package/kb/reference/unique-features.md +2 -1
- package/llms-full.txt +60 -22
- package/manifest.json +2 -2
- package/package.json +5 -2
- package/scripts/benchmark_ecosystem.py +0 -1
- package/scripts/check_split.py +11 -9
- package/scripts/claude_app.py +5 -7
- package/scripts/codex_skill_adapter.py +4 -12
- package/scripts/compile_slm.py +10 -26
- package/scripts/doctor.py +322 -0
- package/scripts/evaluate_skills.py +1 -1
- package/scripts/frontmatter.py +452 -29
- package/scripts/generate_augment_rules.py +4 -4
- package/scripts/generate_cursor_mdc.py +2 -3
- package/scripts/generate_language_rules_skills.py +8 -14
- package/scripts/generate_llms_txt.py +1 -15
- package/scripts/generate_opencode_agents.py +0 -1
- package/scripts/generate_opencode_skills.py +2 -20
- package/scripts/generate_windsurf_rules.py +0 -1
- package/scripts/generator_base.py +0 -1
- package/scripts/inject_hook_cli.py +15 -2
- package/scripts/inject_mcp_cli.py +1 -2
- package/scripts/install.py +32 -1
- package/scripts/install_git_hooks.py +0 -1
- package/scripts/install_steps/ai_tools.py +65 -25
- package/scripts/install_steps/markers.py +6 -6
- package/scripts/install_steps/skill_scope.py +188 -0
- package/scripts/instruction_core.py +5 -8
- package/scripts/merge-hooks.py +13 -3
- package/scripts/pack_codebase.py +1 -1
- package/scripts/surface_manifest.py +6 -7
- package/scripts/validate.py +180 -11
|
@@ -21,7 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
22
|
from codex_skill_adapter import build_opencode_skill_text
|
|
23
23
|
from emission import skills_dir
|
|
24
|
-
from frontmatter import frontmatter_field
|
|
24
|
+
from frontmatter import frontmatter_block, frontmatter_field, frontmatter_sections
|
|
25
25
|
from secure_fs import SecureDestination, SecureTransaction, nearest_existing_root
|
|
26
26
|
|
|
27
27
|
|
|
@@ -43,24 +43,6 @@ class PreparedSkill:
|
|
|
43
43
|
return {Path("SKILL.md"), Path(MANAGED_MANIFEST), *self.files}
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
def _frontmatter_sections(skill_file: Path) -> dict[str, list[str]]:
|
|
47
|
-
text = skill_file.read_text(encoding="utf-8")
|
|
48
|
-
if not text.startswith("---\n"):
|
|
49
|
-
return {}
|
|
50
|
-
parts = text.split("---", 2)
|
|
51
|
-
if len(parts) != 3:
|
|
52
|
-
return {}
|
|
53
|
-
sections: dict[str, list[str]] = {}
|
|
54
|
-
current: str | None = None
|
|
55
|
-
for line in parts[1].strip("\n").splitlines():
|
|
56
|
-
if line and not line[0].isspace() and ":" in line:
|
|
57
|
-
current = line.split(":", 1)[0].strip()
|
|
58
|
-
sections[current] = [line]
|
|
59
|
-
elif current is not None:
|
|
60
|
-
sections[current].append(line)
|
|
61
|
-
return sections
|
|
62
|
-
|
|
63
|
-
|
|
64
46
|
def _portable_body(skill_file: Path) -> str:
|
|
65
47
|
rendered = build_opencode_skill_text(skill_file)
|
|
66
48
|
if not rendered.startswith("---\n"):
|
|
@@ -72,7 +54,7 @@ def _portable_body(skill_file: Path) -> str:
|
|
|
72
54
|
def _render_skill(skill_file: Path) -> str:
|
|
73
55
|
name = frontmatter_field(skill_file, "name")
|
|
74
56
|
description = frontmatter_field(skill_file, "description")
|
|
75
|
-
sections =
|
|
57
|
+
sections = frontmatter_sections(frontmatter_block(skill_file))
|
|
76
58
|
lines = [
|
|
77
59
|
"---",
|
|
78
60
|
f"name: {name}",
|
|
@@ -221,8 +221,17 @@ def _entry_source(entry: dict) -> str | None:
|
|
|
221
221
|
return None
|
|
222
222
|
|
|
223
223
|
|
|
224
|
+
# Handler fields that change *how* a command hook is scheduled, not *what* it
|
|
225
|
+
# runs. A legacy untagged entry that differs only in these is the same hook,
|
|
226
|
+
# otherwise adding `async` or `timeout` to app/hooks.json would leave every
|
|
227
|
+
# existing install running the old copy and the new copy side by side.
|
|
228
|
+
_SCHEDULING_FIELDS = frozenset({
|
|
229
|
+
"async", "asyncRewake", "timeout", "statusMessage", "shell", "once", "if",
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
|
|
224
233
|
def _entry_signature(entry: dict) -> tuple:
|
|
225
|
-
"""Return behavior-defining hook fields without source tags."""
|
|
234
|
+
"""Return behavior-defining hook fields without source or scheduling tags."""
|
|
226
235
|
handlers = []
|
|
227
236
|
for hook in entry.get("hooks", []):
|
|
228
237
|
if not isinstance(hook, dict):
|
|
@@ -230,7 +239,11 @@ def _entry_signature(entry: dict) -> tuple:
|
|
|
230
239
|
continue
|
|
231
240
|
handlers.append(
|
|
232
241
|
tuple(
|
|
233
|
-
sorted(
|
|
242
|
+
sorted(
|
|
243
|
+
(key, value)
|
|
244
|
+
for key, value in hook.items()
|
|
245
|
+
if key != "_source" and key not in _SCHEDULING_FIELDS
|
|
246
|
+
)
|
|
234
247
|
)
|
|
235
248
|
)
|
|
236
249
|
return (entry.get("matcher", ""), tuple(handlers))
|
|
@@ -55,7 +55,6 @@ from __future__ import annotations
|
|
|
55
55
|
|
|
56
56
|
import copy
|
|
57
57
|
import json
|
|
58
|
-
import os
|
|
59
58
|
import re
|
|
60
59
|
import sys
|
|
61
60
|
import urllib.parse
|
|
@@ -163,7 +162,7 @@ def _check_collisions(
|
|
|
163
162
|
|
|
164
163
|
if not force:
|
|
165
164
|
print(
|
|
166
|
-
|
|
165
|
+
"Error: server name collision(s) in .mcp.json (re-run with --force):",
|
|
167
166
|
file=sys.stderr,
|
|
168
167
|
)
|
|
169
168
|
for name, other_source in collisions:
|
package/scripts/install.py
CHANGED
|
@@ -41,6 +41,9 @@ Options:
|
|
|
41
41
|
--persona <p> backend-lead|frontend-lead|devops-eng|junior-dev
|
|
42
42
|
--modules <list> Install specific modules (comma-separated)
|
|
43
43
|
--auto-detect Detect project languages and install matching rules
|
|
44
|
+
--language-skills <s> detected (default): turn off <lang>-rules/<lang>-patterns
|
|
45
|
+
skills for languages no registered project uses;
|
|
46
|
+
all: keep every language skill on (persisted)
|
|
44
47
|
--status Show installed modules and exit
|
|
45
48
|
"""
|
|
46
49
|
from __future__ import annotations
|
|
@@ -50,7 +53,7 @@ import sys
|
|
|
50
53
|
from pathlib import Path
|
|
51
54
|
|
|
52
55
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
53
|
-
from _common import toolkit_dir, app_dir, inject_rule
|
|
56
|
+
from _common import toolkit_dir, app_dir, inject_rule, should_install
|
|
54
57
|
from emission import agent_count as count_agents, skill_count as count_skills
|
|
55
58
|
|
|
56
59
|
# Step modules
|
|
@@ -223,6 +226,7 @@ def parse_args(argv: list[str]) -> dict:
|
|
|
223
226
|
"refresh_base": False,
|
|
224
227
|
"skip_register": False,
|
|
225
228
|
"codex_skills": False,
|
|
229
|
+
"language_skills": "",
|
|
226
230
|
}
|
|
227
231
|
i = 0
|
|
228
232
|
while i < len(argv):
|
|
@@ -283,6 +287,11 @@ def parse_args(argv: list[str]) -> dict:
|
|
|
283
287
|
cfg["skip_register"] = True
|
|
284
288
|
elif arg == "--codex-skills":
|
|
285
289
|
cfg["codex_skills"] = True
|
|
290
|
+
elif arg.startswith("--language-skills="):
|
|
291
|
+
cfg["language_skills"] = arg.split("=", 1)[1]
|
|
292
|
+
elif arg == "--language-skills":
|
|
293
|
+
i += 1
|
|
294
|
+
cfg["language_skills"] = argv[i] if i < len(argv) else ""
|
|
286
295
|
elif arg.startswith("-"):
|
|
287
296
|
print(f"Unknown option: {arg}")
|
|
288
297
|
sys.exit(1)
|
|
@@ -335,6 +344,15 @@ def validate_args(cfg: dict) -> None:
|
|
|
335
344
|
if c and c not in VALID_COMPONENTS:
|
|
336
345
|
errors.append(f"Unknown component in --skip: '{c}' (valid: {', '.join(sorted(VALID_COMPONENTS))})")
|
|
337
346
|
|
|
347
|
+
# Validate --language-skills
|
|
348
|
+
if cfg["language_skills"]:
|
|
349
|
+
from install_steps.skill_scope import VALID_SCOPES
|
|
350
|
+
if cfg["language_skills"] not in VALID_SCOPES:
|
|
351
|
+
errors.append(
|
|
352
|
+
f"Unknown --language-skills value: '{cfg['language_skills']}' "
|
|
353
|
+
f"(valid: {', '.join(VALID_SCOPES)})"
|
|
354
|
+
)
|
|
355
|
+
|
|
338
356
|
# Validate --editors
|
|
339
357
|
if cfg["editors"] and cfg["editors"] != "all":
|
|
340
358
|
for e in cfg["editors"].split(","):
|
|
@@ -848,6 +866,19 @@ def main() -> None:
|
|
|
848
866
|
if is_new:
|
|
849
867
|
print(f" Registered project in {TOOLKIT_DATA_DIR / 'projects.json'}")
|
|
850
868
|
|
|
869
|
+
# Language knowledge skills are symlinked for every language, but their
|
|
870
|
+
# descriptions load into every session. Scope them to the languages the
|
|
871
|
+
# registered projects actually use (after registration, so a new project's
|
|
872
|
+
# language re-enables its skills in the same run). Read-only in dry-run.
|
|
873
|
+
if should_install("skills", only, skip):
|
|
874
|
+
from install_steps.skill_scope import reconcile_language_skill_overrides, resolve_scope
|
|
875
|
+
reconcile_language_skill_overrides(
|
|
876
|
+
toolkit_dir,
|
|
877
|
+
target_dir / ".claude" / "settings.json",
|
|
878
|
+
scope=resolve_scope(cfg["language_skills"]),
|
|
879
|
+
dry_run=dry_run,
|
|
880
|
+
)
|
|
881
|
+
|
|
851
882
|
# A global install is authoritative for Claude Code, so an uploaded Claude
|
|
852
883
|
# app plugin must not feed it in parallel. Uploading the ZIP re-enables the
|
|
853
884
|
# plugin every time, so this is re-asserted on every install/update rather
|
|
@@ -12,6 +12,7 @@ import subprocess
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
from _common import app_dir, inject_section, toolkit_dir
|
|
15
|
+
from frontmatter import FrontmatterError, parse_frontmatter, split_frontmatter
|
|
15
16
|
from codex_skill_adapter import (
|
|
16
17
|
cleanup_codex_skills,
|
|
17
18
|
managed_skill_surface_transaction,
|
|
@@ -965,7 +966,7 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
|
|
|
965
966
|
_apply_extends_config(cwd, merged_config)
|
|
966
967
|
|
|
967
968
|
# Inject language-specific rules into project CLAUDE.md
|
|
968
|
-
_inject_language_rules(cwd, language_modules)
|
|
969
|
+
_inject_language_rules(cwd, language_modules, profile=profile)
|
|
969
970
|
|
|
970
971
|
# Install editor configs only for resolved editors
|
|
971
972
|
_create_local_ai_tool_configs(cwd, rules_dir, resolved_editors,
|
|
@@ -1061,17 +1062,21 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
|
|
|
1061
1062
|
print(" Saved: .softspark-toolkit-extends.json (resolution metadata)")
|
|
1062
1063
|
|
|
1063
1064
|
|
|
1064
|
-
def _inject_language_rules(cwd: Path, language_modules: list[str] | None
|
|
1065
|
+
def _inject_language_rules(cwd: Path, language_modules: list[str] | None,
|
|
1066
|
+
profile: str = "standard") -> None:
|
|
1065
1067
|
"""Install Claude language-rule entrypoints for a project.
|
|
1066
1068
|
|
|
1067
1069
|
Per-language rules (``app/rules/<lang>/``) are NOT injected here -- they
|
|
1068
1070
|
ship as ``<lang>-rules`` knowledge skills under ``app/skills/`` and load
|
|
1069
1071
|
contextually via the Agent Skills progressive-disclosure mechanism.
|
|
1070
1072
|
|
|
1071
|
-
Common rules are written as Claude Code
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1073
|
+
Common rules are written as Claude Code rules under ``.claude/rules/``.
|
|
1074
|
+
Each source rule's ``paths`` frontmatter decides whether it is always-on
|
|
1075
|
+
(``**/*``: coding-style, git-workflow, security) or path-scoped (testing,
|
|
1076
|
+
performance load only for matching files). Current Claude Code guidance
|
|
1077
|
+
targets under 200 lines per ``CLAUDE.md`` file; keeping rule bodies out
|
|
1078
|
+
of it and scoping the ones that are file-type specific keeps startup
|
|
1079
|
+
context smaller.
|
|
1075
1080
|
"""
|
|
1076
1081
|
if not language_modules:
|
|
1077
1082
|
return
|
|
@@ -1081,7 +1086,7 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
1081
1086
|
if not common_dir.is_dir():
|
|
1082
1087
|
return
|
|
1083
1088
|
|
|
1084
|
-
rule_files = _sync_claude_common_rules(cwd, common_dir)
|
|
1089
|
+
rule_files = _sync_claude_common_rules(cwd, common_dir, profile)
|
|
1085
1090
|
|
|
1086
1091
|
# Detect requested per-language modules so we can name the linked skills
|
|
1087
1092
|
# in the marker block. The modules themselves are not inlined.
|
|
@@ -1095,12 +1100,18 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
1095
1100
|
lines: list[str] = ["# Language Rules", ""]
|
|
1096
1101
|
lines.append(
|
|
1097
1102
|
"Common ai-toolkit rules live in `.claude/rules/ai-toolkit-*.md` "
|
|
1098
|
-
"with Claude Code `paths` frontmatter
|
|
1099
|
-
"
|
|
1103
|
+
"with Claude Code `paths` frontmatter instead of expanding this "
|
|
1104
|
+
"CLAUDE.md. Always-on rules load in every session; path-scoped rules "
|
|
1105
|
+
"load only when a matching file is touched."
|
|
1100
1106
|
)
|
|
1101
|
-
if
|
|
1107
|
+
always_on = [p for p, on in rule_files if on]
|
|
1108
|
+
scoped = [p for p, on in rule_files if not on]
|
|
1109
|
+
if always_on:
|
|
1102
1110
|
lines.append("")
|
|
1103
|
-
lines.append("
|
|
1111
|
+
lines.append("Always-on: " + ", ".join(f"`{p}`" for p in always_on) + ".")
|
|
1112
|
+
if scoped:
|
|
1113
|
+
lines.append("")
|
|
1114
|
+
lines.append("Path-scoped: " + ", ".join(f"`{p}`" for p in scoped) + ".")
|
|
1104
1115
|
lines.append("")
|
|
1105
1116
|
lines.append(
|
|
1106
1117
|
"Language-specific rules live in `<lang>-rules` knowledge skills "
|
|
@@ -1132,39 +1143,68 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
|
|
|
1132
1143
|
tmp_path.unlink(missing_ok=True)
|
|
1133
1144
|
|
|
1134
1145
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
end = text.find("\n---", 3)
|
|
1138
|
-
if end != -1:
|
|
1139
|
-
return text[end + 4:].lstrip("\n")
|
|
1140
|
-
return text
|
|
1146
|
+
ALWAYS_ON_RULE_PATHS = ["**/*"]
|
|
1147
|
+
|
|
1141
1148
|
|
|
1149
|
+
def _rule_source(src: Path) -> tuple[str, list[str], list[str]]:
|
|
1150
|
+
"""Return ``(body, paths, profiles)`` for one ``app/rules/common`` file.
|
|
1142
1151
|
|
|
1143
|
-
|
|
1152
|
+
``paths`` defaults to always-on (``**/*``); an empty ``profiles`` means
|
|
1153
|
+
the rule ships in every profile. Shipped rules pass ``validate.py`` before
|
|
1154
|
+
release, so a parse failure here is a broken install, not a soft case.
|
|
1155
|
+
"""
|
|
1156
|
+
text = src.read_text(encoding="utf-8")
|
|
1157
|
+
try:
|
|
1158
|
+
_, body = split_frontmatter(text)
|
|
1159
|
+
meta = parse_frontmatter(text)
|
|
1160
|
+
except FrontmatterError as error:
|
|
1161
|
+
raise RuntimeError(f"invalid frontmatter in {src}: {error}") from error
|
|
1162
|
+
paths = meta.get("paths")
|
|
1163
|
+
profiles = meta.get("profiles")
|
|
1164
|
+
return (
|
|
1165
|
+
body,
|
|
1166
|
+
[str(p) for p in paths] if isinstance(paths, list) and paths else list(ALWAYS_ON_RULE_PATHS),
|
|
1167
|
+
[str(p) for p in profiles] if isinstance(profiles, list) else [],
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _sync_claude_common_rules(cwd: Path, common_dir: Path,
|
|
1172
|
+
profile: str = "standard") -> list[tuple[str, bool]]:
|
|
1144
1173
|
"""Write common ai-toolkit rules as Claude Code path-scoped rules.
|
|
1145
1174
|
|
|
1175
|
+
Each source rule's ``paths`` frontmatter decides its scope; rules
|
|
1176
|
+
without one are always-on. A ``profiles`` frontmatter restricts the rule
|
|
1177
|
+
to those install profiles (``git-team`` ships with ``strict`` only); a
|
|
1178
|
+
managed file whose rule no longer applies is removed, so switching profile
|
|
1179
|
+
on a rerun converges. Returns ``(relative path, always_on)`` pairs.
|
|
1180
|
+
|
|
1146
1181
|
Only ``ai-toolkit-*.md`` files are managed. User-authored files in
|
|
1147
1182
|
``.claude/rules/`` are preserved.
|
|
1148
1183
|
"""
|
|
1149
1184
|
rules_dir = cwd / ".claude" / "rules"
|
|
1150
1185
|
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
1151
1186
|
|
|
1152
|
-
source_files =
|
|
1153
|
-
|
|
1187
|
+
source_files: list[tuple[Path, str, list[str]]] = []
|
|
1188
|
+
for src in sorted(common_dir.glob("*.md")):
|
|
1189
|
+
body, paths, gate = _rule_source(src)
|
|
1190
|
+
if gate and profile not in gate:
|
|
1191
|
+
continue
|
|
1192
|
+
source_files.append((src, body, paths))
|
|
1193
|
+
expected = {f"ai-toolkit-{src.stem}.md" for src, _, _ in source_files}
|
|
1154
1194
|
for stale in sorted(rules_dir.glob("ai-toolkit-*.md")):
|
|
1155
1195
|
if stale.name not in expected:
|
|
1156
1196
|
stale.unlink()
|
|
1157
1197
|
|
|
1158
|
-
written: list[str] = []
|
|
1159
|
-
for src in source_files:
|
|
1160
|
-
body =
|
|
1198
|
+
written: list[tuple[str, bool]] = []
|
|
1199
|
+
for src, body, paths in source_files:
|
|
1200
|
+
body = body.lstrip("\n").rstrip()
|
|
1161
1201
|
rel = Path(".claude") / "rules" / f"ai-toolkit-{src.stem}.md"
|
|
1162
1202
|
target = cwd / rel
|
|
1163
1203
|
target.write_text(
|
|
1164
1204
|
"\n".join([
|
|
1165
1205
|
"---",
|
|
1166
1206
|
"paths:",
|
|
1167
|
-
' - "
|
|
1207
|
+
*[f' - "{p}"' for p in paths],
|
|
1168
1208
|
"---",
|
|
1169
1209
|
"",
|
|
1170
1210
|
body,
|
|
@@ -1172,7 +1212,7 @@ def _sync_claude_common_rules(cwd: Path, common_dir: Path) -> list[str]:
|
|
|
1172
1212
|
]),
|
|
1173
1213
|
encoding="utf-8",
|
|
1174
1214
|
)
|
|
1175
|
-
written.append(rel.as_posix())
|
|
1215
|
+
written.append((rel.as_posix(), paths == ALWAYS_ON_RULE_PATHS))
|
|
1176
1216
|
|
|
1177
1217
|
return written
|
|
1178
1218
|
|
|
@@ -214,10 +214,10 @@ def _refresh_url_rules(rules_dir: Path) -> None:
|
|
|
214
214
|
except Exception as exc:
|
|
215
215
|
if rule_file.is_file():
|
|
216
216
|
print(f" Warning: could not refresh '{rule_name}' from {url}: {exc}")
|
|
217
|
-
print(
|
|
217
|
+
print(" Using cached version.")
|
|
218
218
|
else:
|
|
219
219
|
print(f" Warning: could not fetch '{rule_name}' from {url}: {exc}")
|
|
220
|
-
print(
|
|
220
|
+
print(" No cached version — rule will be skipped.")
|
|
221
221
|
|
|
222
222
|
|
|
223
223
|
def refresh_url_hooks(target_dir: str | None = None) -> None:
|
|
@@ -250,10 +250,10 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
|
|
|
250
250
|
except Exception as exc:
|
|
251
251
|
if cached_file.is_file():
|
|
252
252
|
print(f" Warning: could not refresh '{hook_name}' from {url}: {exc}")
|
|
253
|
-
print(
|
|
253
|
+
print(" Using cached version.")
|
|
254
254
|
else:
|
|
255
255
|
print(f" Warning: could not fetch '{hook_name}' from {url}: {exc}")
|
|
256
|
-
print(
|
|
256
|
+
print(" No cached version — hook will be skipped.")
|
|
257
257
|
continue
|
|
258
258
|
|
|
259
259
|
# Re-inject from cached file
|
|
@@ -291,10 +291,10 @@ def refresh_url_mcp(target_dir: str | None = None) -> None:
|
|
|
291
291
|
except Exception as exc:
|
|
292
292
|
if cached_file.is_file():
|
|
293
293
|
print(f" Warning: could not refresh '{template_name}' from {url}: {exc}")
|
|
294
|
-
print(
|
|
294
|
+
print(" Using cached version.")
|
|
295
295
|
else:
|
|
296
296
|
print(f" Warning: could not fetch '{template_name}' from {url}: {exc}")
|
|
297
|
-
print(
|
|
297
|
+
print(" No cached version — template will be skipped.")
|
|
298
298
|
continue
|
|
299
299
|
|
|
300
300
|
if cached_file.is_file():
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
|
|
3
|
+
# Source: https://github.com/softspark/ai-toolkit
|
|
4
|
+
|
|
5
|
+
"""Scope language knowledge skills to the stacks a user actually works in.
|
|
6
|
+
|
|
7
|
+
The global install symlinks every ``<lang>-rules`` / ``<lang>-patterns``
|
|
8
|
+
skill into ``~/.claude/skills``. Each one's description then sits in the
|
|
9
|
+
model-visible skill listing of every session, whether or not the user has a
|
|
10
|
+
single project in that language. This step reads the languages detected
|
|
11
|
+
across the registered projects (``projects.json``) and turns the others off
|
|
12
|
+
through Claude Code's ``skillOverrides`` setting.
|
|
13
|
+
|
|
14
|
+
Rules of engagement:
|
|
15
|
+
|
|
16
|
+
* Evidence first. With no registered project on disk there is nothing to
|
|
17
|
+
judge, so nothing is disabled.
|
|
18
|
+
* Only entries this step wrote are ever removed again. They are tracked in
|
|
19
|
+
``state.json`` under ``managed_skill_overrides``; a user's own override is
|
|
20
|
+
left alone even when it names a language skill.
|
|
21
|
+
* ``--language-skills all`` restores every managed entry and persists the
|
|
22
|
+
choice so later ``install`` / ``update`` runs do not prune again.
|
|
23
|
+
* Reversible by hand: delete the key from ``~/.claude/settings.json``.
|
|
24
|
+
|
|
25
|
+
Stdlib-only.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from install_steps.detect_language import detect_languages
|
|
34
|
+
from install_steps.install_state import load_state, save_state
|
|
35
|
+
from install_steps.project_registry import load_registry
|
|
36
|
+
|
|
37
|
+
LANGUAGE_SKILL_SUFFIXES = ("rules", "patterns")
|
|
38
|
+
SCOPE_ALL = "all"
|
|
39
|
+
SCOPE_DETECTED = "detected"
|
|
40
|
+
VALID_SCOPES = (SCOPE_ALL, SCOPE_DETECTED)
|
|
41
|
+
STATE_SCOPE_KEY = "language_skill_scope"
|
|
42
|
+
STATE_MANAGED_KEY = "managed_skill_overrides"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def language_skill_names(toolkit_dir: Path) -> dict[str, str]:
|
|
46
|
+
"""Map each shipped language knowledge skill to its language.
|
|
47
|
+
|
|
48
|
+
A skill counts as a language skill when it is named ``<lang>-rules`` or
|
|
49
|
+
``<lang>-patterns`` and ``app/rules/<lang>/`` exists. ``flutter-patterns``
|
|
50
|
+
therefore does not (no ``app/rules/flutter``), which is deliberate:
|
|
51
|
+
only skills generated from a language rule set are scoped.
|
|
52
|
+
"""
|
|
53
|
+
rules_root = toolkit_dir / "app" / "rules"
|
|
54
|
+
skills_root = toolkit_dir / "app" / "skills"
|
|
55
|
+
if not rules_root.is_dir() or not skills_root.is_dir():
|
|
56
|
+
return {}
|
|
57
|
+
languages = {p.name for p in rules_root.iterdir() if p.is_dir() and p.name != "common"}
|
|
58
|
+
mapping: dict[str, str] = {}
|
|
59
|
+
for skill_dir in sorted(skills_root.iterdir()):
|
|
60
|
+
if not skill_dir.is_dir() or "-" not in skill_dir.name:
|
|
61
|
+
continue
|
|
62
|
+
lang, _, suffix = skill_dir.name.rpartition("-")
|
|
63
|
+
if suffix in LANGUAGE_SKILL_SUFFIXES and lang in languages:
|
|
64
|
+
mapping[skill_dir.name] = lang
|
|
65
|
+
return mapping
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def detected_languages_from_registry(toolkit_dir: Path) -> set[str] | None:
|
|
69
|
+
"""Union of languages detected across registered projects that still exist.
|
|
70
|
+
|
|
71
|
+
Returns ``None`` when no registered project directory exists, which
|
|
72
|
+
callers must read as "no evidence", not "no languages".
|
|
73
|
+
"""
|
|
74
|
+
found: set[str] = set()
|
|
75
|
+
seen_project = False
|
|
76
|
+
for entry in load_registry():
|
|
77
|
+
path = entry.get("path")
|
|
78
|
+
if not isinstance(path, str):
|
|
79
|
+
continue
|
|
80
|
+
project_dir = Path(path)
|
|
81
|
+
if not project_dir.is_dir():
|
|
82
|
+
continue
|
|
83
|
+
seen_project = True
|
|
84
|
+
for module in detect_languages(project_dir, toolkit_dir):
|
|
85
|
+
if module.startswith("rules-") and module != "rules-common":
|
|
86
|
+
found.add(module[len("rules-"):])
|
|
87
|
+
return found if seen_project else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_settings(settings_path: Path) -> dict[str, Any]:
|
|
91
|
+
if not settings_path.is_file():
|
|
92
|
+
return {}
|
|
93
|
+
try:
|
|
94
|
+
with open(settings_path, encoding="utf-8") as f:
|
|
95
|
+
data = json.load(f)
|
|
96
|
+
except (OSError, json.JSONDecodeError):
|
|
97
|
+
return {}
|
|
98
|
+
return data if isinstance(data, dict) else {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _save_settings(settings_path: Path, data: dict[str, Any]) -> None:
|
|
102
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
104
|
+
json.dump(data, f, indent=4)
|
|
105
|
+
f.write("\n")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_scope(requested: str, state: dict[str, Any] | None = None) -> str:
|
|
109
|
+
"""Pick the effective scope: explicit flag wins, else the persisted choice."""
|
|
110
|
+
if requested in VALID_SCOPES:
|
|
111
|
+
return requested
|
|
112
|
+
persisted = (state if state is not None else load_state()).get(STATE_SCOPE_KEY)
|
|
113
|
+
return persisted if persisted in VALID_SCOPES else SCOPE_DETECTED
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def reconcile_language_skill_overrides(
|
|
117
|
+
toolkit_dir: Path,
|
|
118
|
+
settings_path: Path,
|
|
119
|
+
*,
|
|
120
|
+
scope: str,
|
|
121
|
+
dry_run: bool = False,
|
|
122
|
+
) -> tuple[list[str], list[str]]:
|
|
123
|
+
"""Bring ``skillOverrides`` in line with the detected language set.
|
|
124
|
+
|
|
125
|
+
Returns ``(disabled, restored)`` skill names. Prints one line per
|
|
126
|
+
outcome in the install's `` Verb: detail`` style.
|
|
127
|
+
"""
|
|
128
|
+
state = load_state()
|
|
129
|
+
managed: set[str] = {
|
|
130
|
+
name for name in state.get(STATE_MANAGED_KEY, []) if isinstance(name, str)
|
|
131
|
+
}
|
|
132
|
+
skills = language_skill_names(toolkit_dir)
|
|
133
|
+
settings = _load_settings(settings_path)
|
|
134
|
+
overrides = settings.get("skillOverrides")
|
|
135
|
+
if not isinstance(overrides, dict):
|
|
136
|
+
overrides = {}
|
|
137
|
+
|
|
138
|
+
disabled: list[str] = []
|
|
139
|
+
restored: list[str] = []
|
|
140
|
+
|
|
141
|
+
if scope == SCOPE_ALL:
|
|
142
|
+
languages: set[str] | None = None
|
|
143
|
+
else:
|
|
144
|
+
languages = detected_languages_from_registry(toolkit_dir)
|
|
145
|
+
|
|
146
|
+
for name, lang in sorted(skills.items()):
|
|
147
|
+
keep_on = languages is None or lang in languages
|
|
148
|
+
if keep_on:
|
|
149
|
+
if name in managed and overrides.get(name) == "off":
|
|
150
|
+
overrides.pop(name)
|
|
151
|
+
restored.append(name)
|
|
152
|
+
managed.discard(name)
|
|
153
|
+
continue
|
|
154
|
+
if name in overrides:
|
|
155
|
+
# The user (or an earlier run) already has an opinion; a run never
|
|
156
|
+
# adopts an entry it did not write.
|
|
157
|
+
continue
|
|
158
|
+
overrides[name] = "off"
|
|
159
|
+
managed.add(name)
|
|
160
|
+
disabled.append(name)
|
|
161
|
+
|
|
162
|
+
if dry_run:
|
|
163
|
+
if languages is None and scope != SCOPE_ALL:
|
|
164
|
+
print(" Would skip: language skill scoping (no registered project on disk)")
|
|
165
|
+
for name in disabled:
|
|
166
|
+
print(f" Would disable: skill {name} (skillOverrides)")
|
|
167
|
+
for name in restored:
|
|
168
|
+
print(f" Would restore: skill {name} (skillOverrides)")
|
|
169
|
+
return disabled, restored
|
|
170
|
+
|
|
171
|
+
if disabled or restored:
|
|
172
|
+
if overrides:
|
|
173
|
+
settings["skillOverrides"] = overrides
|
|
174
|
+
else:
|
|
175
|
+
settings.pop("skillOverrides", None)
|
|
176
|
+
_save_settings(settings_path, settings)
|
|
177
|
+
save_state({STATE_SCOPE_KEY: scope, STATE_MANAGED_KEY: sorted(managed)})
|
|
178
|
+
|
|
179
|
+
if languages is None and scope != SCOPE_ALL:
|
|
180
|
+
print(" Skipped: language skill scoping (no registered project on disk; run install --local in a project first)")
|
|
181
|
+
elif disabled:
|
|
182
|
+
print(f" Disabled: {len(disabled)} language skill(s) outside your detected stacks "
|
|
183
|
+
f"({', '.join(disabled)})")
|
|
184
|
+
if restored:
|
|
185
|
+
print(f" Restored: {len(restored)} language skill(s) ({', '.join(restored)})")
|
|
186
|
+
if languages is not None and not disabled and not restored and scope == SCOPE_DETECTED:
|
|
187
|
+
print(f" Language skills: scoped to {', '.join(sorted(languages)) or 'no detected languages'} (no change)")
|
|
188
|
+
return disabled, restored
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import re
|
|
9
|
+
import sys
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
from frontmatter import split_frontmatter # noqa: E402
|
|
14
|
+
|
|
11
15
|
|
|
12
16
|
CONSTITUTION_PATH = (
|
|
13
17
|
Path(__file__).resolve().parent.parent / "app" / "constitution.md"
|
|
@@ -16,14 +20,7 @@ CONSTITUTION_PATH = (
|
|
|
16
20
|
|
|
17
21
|
def _strip_frontmatter(text: str) -> str:
|
|
18
22
|
"""Return Markdown after an optional leading YAML frontmatter block."""
|
|
19
|
-
|
|
20
|
-
if not lines or lines[0] != "---":
|
|
21
|
-
return text.strip()
|
|
22
|
-
try:
|
|
23
|
-
closing = lines.index("---", 1)
|
|
24
|
-
except ValueError:
|
|
25
|
-
return text.strip()
|
|
26
|
-
return "\n".join(lines[closing + 1:]).strip()
|
|
23
|
+
return split_frontmatter(text)[1].strip()
|
|
27
24
|
|
|
28
25
|
|
|
29
26
|
def read_constitution(path: Path = CONSTITUTION_PATH) -> str:
|
package/scripts/merge-hooks.py
CHANGED
|
@@ -123,12 +123,22 @@ def _is_retired_toolkit_entry(entry: dict) -> bool:
|
|
|
123
123
|
return True
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
# Handler fields that change *how* a command hook is scheduled, not *what* it
|
|
127
|
+
# runs. A legacy untagged entry that differs only in these is the same hook;
|
|
128
|
+
# otherwise adding `async` or `timeout` in app/hooks.json would leave every
|
|
129
|
+
# existing install running the old copy and the new copy side by side.
|
|
130
|
+
_SCHEDULING_FIELDS = frozenset({
|
|
131
|
+
"async", "asyncRewake", "timeout", "statusMessage", "shell", "once", "if",
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
|
|
126
135
|
def _entry_signature(entry: dict) -> tuple:
|
|
127
136
|
"""Return the behavior-defining parts of a hook entry.
|
|
128
137
|
|
|
129
138
|
Older ai-toolkit installs wrote hook entries without ``_source``. Matching
|
|
130
|
-
on the event, matcher, and handler payload
|
|
131
|
-
those legacy duplicates while preserving unrelated
|
|
139
|
+
on the event, matcher, and handler payload (minus scheduling fields) lets
|
|
140
|
+
current installs remove those legacy duplicates while preserving unrelated
|
|
141
|
+
user hooks.
|
|
132
142
|
"""
|
|
133
143
|
handlers = []
|
|
134
144
|
for hook in entry.get("hooks", []):
|
|
@@ -138,7 +148,7 @@ def _entry_signature(entry: dict) -> tuple:
|
|
|
138
148
|
handlers.append(tuple(sorted(
|
|
139
149
|
(key, value)
|
|
140
150
|
for key, value in hook.items()
|
|
141
|
-
if key != "_source"
|
|
151
|
+
if key != "_source" and key not in _SCHEDULING_FIELDS
|
|
142
152
|
)))
|
|
143
153
|
return (entry.get("matcher", ""), tuple(handlers))
|
|
144
154
|
|
package/scripts/pack_codebase.py
CHANGED