@softspark/ai-toolkit 4.30.3 → 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 +123 -0
- package/README.md +32 -25
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/agents/code-reviewer.md +14 -0
- package/app/agents/frontend-specialist.md +27 -24
- package/app/agents/infrastructure-validator.md +2 -2
- package/app/claude-app/hooks/hooks.json +4 -2
- package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +74 -17
- package/app/hooks/quality-gate.sh +9 -2
- package/app/hooks.json +4 -2
- package/app/personas/frontend-lead.md +20 -12
- package/app/rules/claude-toolkit-rules.md +4 -1
- 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/app/rules/edit-discipline.md +37 -0
- package/app/skills/app-builder/SKILL.md +37 -6
- package/app/skills/claude-toolkit-rules/SKILL.md +37 -0
- package/app/skills/design-engineering/SKILL.md +87 -102
- package/app/skills/design-engineering/reference/animation-recipes.md +67 -0
- package/app/skills/design-engineering/reference/component-craft.md +28 -0
- package/app/skills/documentation-standards/SKILL.md +1 -1
- package/app/skills/edit-discipline/SKILL.md +50 -0
- package/app/skills/git-conventions/SKILL.md +18 -0
- package/app/skills/output-mode/SKILL.md +41 -0
- package/app/skills/quality-gates/SKILL.md +23 -0
- package/app/skills/review/SKILL.md +10 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
- package/bin/ai-toolkit.js +2 -0
- package/kb/procedures/{ecosystem-sync-sop.md → sop-ecosystem-sync.md} +2 -2
- package/kb/procedures/{maintenance-sop.md → sop-maintenance.md} +6 -3
- package/kb/procedures/{post-release-testing-sop.md → sop-post-release-testing.md} +3 -3
- package/kb/procedures/sop-pre-commit.md +96 -0
- package/kb/procedures/{release-preparation-sop.md → sop-release.md} +6 -6
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/cli-reference.md +3 -2
- package/kb/reference/copilot-compatibility.md +1 -1
- 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/licensing.md +2 -2
- package/kb/reference/quick-wins-implementation-summary.md +1 -1
- package/kb/reference/supported-tools-registry.md +2 -2
- package/kb/reference/unique-features.md +2 -1
- package/kb/troubleshooting/plugin-double-load.md +1 -1
- package/llms-full.txt +1016 -871
- package/llms.txt +6 -5
- package/manifest.json +2 -2
- package/package.json +6 -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_toolkit_rules_skills.py +189 -0
- 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 +181 -12
- /package/kb/procedures/{release-verification-sop.md → sop-release-verification.md} +0 -0
|
@@ -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
|
@@ -44,6 +44,7 @@ from pathlib import Path
|
|
|
44
44
|
|
|
45
45
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
46
46
|
from _common import toolkit_dir as default_toolkit_dir
|
|
47
|
+
from frontmatter import FrontmatterError, load_frontmatter
|
|
47
48
|
|
|
48
49
|
MANIFEST_RELPATH = Path("app") / "surface.json"
|
|
49
50
|
|
|
@@ -52,14 +53,12 @@ CLI_COMMAND_RE = re.compile(r"^\s+'?([a-z][a-z0-9-]*)'?\s*:")
|
|
|
52
53
|
|
|
53
54
|
|
|
54
55
|
def _frontmatter_fields(path: Path) -> set[str]:
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
"""Top-level frontmatter keys of one file; a file the subset parser
|
|
57
|
+
refuses contributes nothing, which the removal check then reports."""
|
|
58
|
+
try:
|
|
59
|
+
return {key for key in load_frontmatter(path, strict=False) if FM_FIELD_RE.match(f"{key}:")}
|
|
60
|
+
except FrontmatterError:
|
|
57
61
|
return set()
|
|
58
|
-
return {
|
|
59
|
-
m.group(1)
|
|
60
|
-
for line in parts[1].splitlines()
|
|
61
|
-
if (m := FM_FIELD_RE.match(line))
|
|
62
|
-
}
|
|
63
62
|
|
|
64
63
|
|
|
65
64
|
def collect_surface(tk_dir: Path) -> dict:
|
package/scripts/validate.py
CHANGED
|
@@ -27,6 +27,7 @@ from pathlib import Path
|
|
|
27
27
|
|
|
28
28
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
29
|
from _common import toolkit_dir as default_toolkit_dir, frontmatter_field
|
|
30
|
+
from frontmatter import FrontmatterError, parse_scalar
|
|
30
31
|
from plugin_schema import validate_manifest as _validate_plugin_manifest_schema
|
|
31
32
|
from plugin_schema import validate_references as _validate_plugin_references
|
|
32
33
|
|
|
@@ -147,21 +148,34 @@ MAX_EMITTED_SKILL_NODES = 10_000
|
|
|
147
148
|
#
|
|
148
149
|
# Ratchet: lower WARN by 2_000 each release until it reaches 12_000. Never lower a
|
|
149
150
|
# threshold in the same change that something violates it — split the skill first,
|
|
150
|
-
# then tighten. The step lives in kb/procedures/release
|
|
151
|
+
# then tighten. The step lives in kb/procedures/sop-release.md so it
|
|
151
152
|
# does not rot as a comment nobody reads.
|
|
152
153
|
SKILL_BODY_BUDGET_ERROR = 20_000
|
|
153
154
|
SKILL_BODY_BUDGET_WARN = 18_000
|
|
154
155
|
|
|
156
|
+
# Skill description budget, in characters of the decoded description text.
|
|
157
|
+
#
|
|
158
|
+
# Every model-invocable skill's description sits in the listing that loads at
|
|
159
|
+
# the start of every session, so a long description is paid for on every turn
|
|
160
|
+
# of every user, not just when the skill fires. The Agent Skills spec caps the
|
|
161
|
+
# field at 1024 characters; anything past that is truncated by the runtime.
|
|
162
|
+
SKILL_DESCRIPTION_LIMIT = 1024
|
|
163
|
+
SKILL_DESCRIPTION_BUDGET_WARN = 400
|
|
164
|
+
|
|
155
165
|
VALID_RULE_CATEGORIES = frozenset({
|
|
156
166
|
"coding-style",
|
|
157
167
|
"testing",
|
|
158
168
|
"security",
|
|
159
169
|
"performance",
|
|
160
170
|
"git-workflow",
|
|
171
|
+
"git-team",
|
|
161
172
|
"patterns",
|
|
162
173
|
"frameworks",
|
|
163
174
|
})
|
|
164
175
|
|
|
176
|
+
# Install profiles a common rule may restrict itself to via `profiles:`.
|
|
177
|
+
VALID_RULE_PROFILES = frozenset({"minimal", "standard", "strict", "full"})
|
|
178
|
+
|
|
165
179
|
COMMON_RULE_CATEGORIES = frozenset({
|
|
166
180
|
"coding-style",
|
|
167
181
|
"testing",
|
|
@@ -256,6 +270,139 @@ def _fm_has(lines: list[str], field: str) -> bool:
|
|
|
256
270
|
return any(line.startswith(f"{field}:") for line in lines)
|
|
257
271
|
|
|
258
272
|
|
|
273
|
+
def _fm_description(fm_lines: list[str]) -> tuple[str, str]:
|
|
274
|
+
"""Return ``(raw first-line value, decoded text)`` for ``description:``.
|
|
275
|
+
|
|
276
|
+
Handles the three spellings skills use: a plain scalar, a quoted scalar,
|
|
277
|
+
and a block scalar (``>-`` / ``|``) whose text continues on indented lines.
|
|
278
|
+
"""
|
|
279
|
+
for index, line in enumerate(fm_lines):
|
|
280
|
+
if not line.startswith("description:"):
|
|
281
|
+
continue
|
|
282
|
+
raw = line[len("description:"):].strip()
|
|
283
|
+
if raw and raw[0] in ">|":
|
|
284
|
+
continuation: list[str] = []
|
|
285
|
+
for nxt in fm_lines[index + 1:]:
|
|
286
|
+
if nxt.strip() and not nxt[0].isspace():
|
|
287
|
+
break
|
|
288
|
+
continuation.append(nxt.strip())
|
|
289
|
+
return raw, " ".join(part for part in continuation if part)
|
|
290
|
+
return raw, _frontmatter_scalar(raw)
|
|
291
|
+
return "", ""
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _validate_skill_description(label: str, fm_lines: list[str],
|
|
295
|
+
vr: ValidationResult) -> None:
|
|
296
|
+
"""Enforce the description budget and reject ambiguous plain scalars."""
|
|
297
|
+
raw, text = _fm_description(fm_lines)
|
|
298
|
+
if not raw:
|
|
299
|
+
return
|
|
300
|
+
is_plain = raw[0] not in "\"'>|"
|
|
301
|
+
if is_plain and (": " in raw or " #" in raw):
|
|
302
|
+
# A plain scalar with `: ` or ` #` parses as a nested mapping or a
|
|
303
|
+
# comment under strict YAML. Claude Code tolerates it today; a stricter
|
|
304
|
+
# parser drops every field, including allowed-tools, without a warning.
|
|
305
|
+
vr.error(
|
|
306
|
+
f"{label}: description is an unquoted scalar containing ': ' or ' #' "
|
|
307
|
+
"- quote it or use a '>-' block scalar"
|
|
308
|
+
)
|
|
309
|
+
length = len(text)
|
|
310
|
+
if length > SKILL_DESCRIPTION_LIMIT:
|
|
311
|
+
vr.error(
|
|
312
|
+
f"{label}: description is {length} characters "
|
|
313
|
+
f"(limit {SKILL_DESCRIPTION_LIMIT}) - the runtime truncates it"
|
|
314
|
+
)
|
|
315
|
+
elif length > SKILL_DESCRIPTION_BUDGET_WARN:
|
|
316
|
+
vr.warn(
|
|
317
|
+
f"{label}: description is {length} characters "
|
|
318
|
+
f"(budget {SKILL_DESCRIPTION_BUDGET_WARN}) - it loads in every session"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _validate_rule_profiles(rel: str, language: str, fm_lines: list[str],
|
|
323
|
+
vr: ValidationResult) -> int:
|
|
324
|
+
"""Validate an optional ``profiles:`` gate on a common rule source file.
|
|
325
|
+
|
|
326
|
+
Same block-list form as ``paths``; every value must be an existing install
|
|
327
|
+
profile. Per-language rules ship as skills and have no profile, so the key
|
|
328
|
+
is rejected outside ``app/rules/common/``.
|
|
329
|
+
"""
|
|
330
|
+
if language != "common":
|
|
331
|
+
vr.error(f"{rel} - profiles is only meaningful for common rules")
|
|
332
|
+
return 1
|
|
333
|
+
errors = 0
|
|
334
|
+
items: list[str] = []
|
|
335
|
+
in_block = False
|
|
336
|
+
for line in fm_lines:
|
|
337
|
+
if line.startswith("profiles:"):
|
|
338
|
+
if line[len("profiles:"):].strip():
|
|
339
|
+
vr.error(f"{rel} - profiles must be a block list, not inline: {line.strip()}")
|
|
340
|
+
return 1
|
|
341
|
+
in_block = True
|
|
342
|
+
continue
|
|
343
|
+
if not in_block:
|
|
344
|
+
continue
|
|
345
|
+
stripped = line.strip()
|
|
346
|
+
if not stripped:
|
|
347
|
+
continue
|
|
348
|
+
if not line.startswith(" - "):
|
|
349
|
+
break
|
|
350
|
+
item = stripped[2:].strip().strip('"')
|
|
351
|
+
if item not in VALID_RULE_PROFILES:
|
|
352
|
+
vr.error(
|
|
353
|
+
f"{rel} - unknown profile '{item}' "
|
|
354
|
+
f"(valid: {', '.join(sorted(VALID_RULE_PROFILES))})"
|
|
355
|
+
)
|
|
356
|
+
errors += 1
|
|
357
|
+
continue
|
|
358
|
+
items.append(item)
|
|
359
|
+
if not items and errors == 0:
|
|
360
|
+
vr.error(f"{rel} - profiles block is empty (drop the key to ship in every profile)")
|
|
361
|
+
errors += 1
|
|
362
|
+
return errors
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _validate_rule_paths(rel: str, fm_lines: list[str], vr: ValidationResult) -> int:
|
|
366
|
+
"""Validate an optional ``paths:`` scope block on a rule source file.
|
|
367
|
+
|
|
368
|
+
Mirrors what ``install_steps.ai_tools._rule_paths`` reads: a bare
|
|
369
|
+
``paths:`` key followed by one or more `` - "glob"`` items. Returns the
|
|
370
|
+
number of errors reported.
|
|
371
|
+
"""
|
|
372
|
+
errors = 0
|
|
373
|
+
items: list[str] = []
|
|
374
|
+
in_paths = False
|
|
375
|
+
for line in fm_lines:
|
|
376
|
+
if line.startswith("paths:"):
|
|
377
|
+
if line[len("paths:"):].strip():
|
|
378
|
+
vr.error(f"{rel} - paths must be a block list, not inline: {line.strip()}")
|
|
379
|
+
return 1
|
|
380
|
+
in_paths = True
|
|
381
|
+
continue
|
|
382
|
+
if not in_paths:
|
|
383
|
+
continue
|
|
384
|
+
stripped = line.strip()
|
|
385
|
+
if not stripped:
|
|
386
|
+
continue
|
|
387
|
+
if not line.startswith(" - "):
|
|
388
|
+
break
|
|
389
|
+
item = stripped[2:].strip()
|
|
390
|
+
if len(item) < 3 or item[0] != '"' or item[-1] != '"':
|
|
391
|
+
vr.error(f"{rel} - paths entry must be a double-quoted glob: {stripped}")
|
|
392
|
+
errors += 1
|
|
393
|
+
continue
|
|
394
|
+
glob_value = item[1:-1]
|
|
395
|
+
if not glob_value or any(ch in glob_value for ch in ' \\"'):
|
|
396
|
+
vr.error(f"{rel} - paths glob contains whitespace, backslash, or quote: {stripped}")
|
|
397
|
+
errors += 1
|
|
398
|
+
continue
|
|
399
|
+
items.append(glob_value)
|
|
400
|
+
if not items and errors == 0:
|
|
401
|
+
vr.error(f"{rel} - paths block is empty (drop the key for an always-on rule)")
|
|
402
|
+
errors += 1
|
|
403
|
+
return errors
|
|
404
|
+
|
|
405
|
+
|
|
259
406
|
def _validate_invocation_metadata(label: str, fm_lines: list[str],
|
|
260
407
|
vr: ValidationResult) -> None:
|
|
261
408
|
"""Reject metadata spellings that DSH interprets differently or ignores."""
|
|
@@ -296,14 +443,16 @@ def _validate_invocation_metadata(label: str, fm_lines: list[str],
|
|
|
296
443
|
|
|
297
444
|
|
|
298
445
|
def _frontmatter_scalar(raw_value: str) -> str:
|
|
299
|
-
"""Decode the
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
446
|
+
"""Decode one scalar the way the shared parser does, never raising.
|
|
447
|
+
|
|
448
|
+
Validation reports problems as findings; a value the subset parser
|
|
449
|
+
refuses (anchor, tag, unterminated quote) decodes to its raw text here and
|
|
450
|
+
is caught by the dedicated checks that follow.
|
|
451
|
+
"""
|
|
452
|
+
try:
|
|
453
|
+
return parse_scalar(raw_value, strict=False)
|
|
454
|
+
except FrontmatterError:
|
|
455
|
+
return raw_value.strip()
|
|
307
456
|
|
|
308
457
|
|
|
309
458
|
def _parse_supported_top_level_entry(
|
|
@@ -672,9 +821,7 @@ def _validate_skill_frontmatter(tk_dir: Path, skill_path: Path,
|
|
|
672
821
|
f"(budget {SKILL_BODY_BUDGET_WARN}) - move detail into reference/"
|
|
673
822
|
)
|
|
674
823
|
|
|
675
|
-
|
|
676
|
-
if len(desc_value) > 1024:
|
|
677
|
-
vr.warn(f"{name} - Description exceeds 1024 characters")
|
|
824
|
+
_validate_skill_description(f"skills/{name}/SKILL.md", fm_lines, vr)
|
|
678
825
|
|
|
679
826
|
|
|
680
827
|
def _validate_skill_script_invocations(skill_path: Path, vr: ValidationResult) -> None:
|
|
@@ -1104,6 +1251,17 @@ def validate_language_rules(tk_dir: Path, vr: ValidationResult) -> None:
|
|
|
1104
1251
|
vr.error(f"{rel} filename does not match category '{category}'")
|
|
1105
1252
|
rule_errors += 1
|
|
1106
1253
|
|
|
1254
|
+
# Optional Claude Code `paths` scope. The installer reads only the
|
|
1255
|
+
# block-list form (` - "glob"`), so reject anything else here
|
|
1256
|
+
# rather than letting a rule silently fall back to always-on.
|
|
1257
|
+
if _fm_has(fm_lines, "paths"):
|
|
1258
|
+
rule_errors += _validate_rule_paths(rel, fm_lines, vr)
|
|
1259
|
+
|
|
1260
|
+
# Optional `profiles` gate (common rules only): same block-list
|
|
1261
|
+
# form, values limited to the install profiles that exist.
|
|
1262
|
+
if _fm_has(fm_lines, "profiles"):
|
|
1263
|
+
rule_errors += _validate_rule_profiles(rel, language, fm_lines, vr)
|
|
1264
|
+
|
|
1107
1265
|
for category in sorted(expected - seen):
|
|
1108
1266
|
vr.error(f"app/rules/{language} missing required rule category: {category}")
|
|
1109
1267
|
rule_errors += 1
|
|
@@ -1230,6 +1388,17 @@ def _validate_pack_refs(tk_dir: Path, pack_path: Path, d: dict,
|
|
|
1230
1388
|
for err in ref_errors:
|
|
1231
1389
|
vr.error(f"app/plugins/{pack_name}/plugin.json {err}")
|
|
1232
1390
|
|
|
1391
|
+
# Pack skills bypass validate_skills() (they live outside app/skills), yet
|
|
1392
|
+
# they are installed as skills. Apply the same description gate so a pack
|
|
1393
|
+
# cannot ship a listing-bloating or strict-YAML-hostile description.
|
|
1394
|
+
pack_skills_dir = pack_path / "skills"
|
|
1395
|
+
if pack_skills_dir.is_dir():
|
|
1396
|
+
for skill_file in sorted(pack_skills_dir.glob("*/SKILL.md")):
|
|
1397
|
+
if not _has_frontmatter(skill_file):
|
|
1398
|
+
continue
|
|
1399
|
+
rel = str(skill_file.relative_to(tk_dir))
|
|
1400
|
+
_validate_skill_description(rel, _parse_frontmatter_lines(skill_file), vr)
|
|
1401
|
+
|
|
1233
1402
|
hooks_dir = pack_path / "hooks"
|
|
1234
1403
|
if hooks_dir.is_dir():
|
|
1235
1404
|
for hook in sorted(hooks_dir.glob("*.sh")):
|
|
File without changes
|