@softspark/ai-toolkit 4.31.0 → 4.32.1
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 +121 -0
- package/README.md +21 -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/app/skills/analyze/scripts/complexity.py +3 -0
- package/app/skills/deploy/scripts/pre_deploy_check.py +13 -6
- package/app/skills/docs/scripts/doc-inventory.py +3 -0
- package/app/skills/explain/scripts/dependency-graph.py +3 -0
- package/app/skills/migrate/scripts/migration-status.py +3 -0
- package/app/skills/refactor/scripts/refactor-scan.py +3 -0
- 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/plugin-pack-conventions.md +3 -3
- package/kb/reference/unique-features.md +2 -1
- package/llms-full.txt +63 -25
- 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/plugin.py +128 -16
- package/scripts/surface_manifest.py +6 -7
- package/scripts/validate.py +180 -11
package/scripts/plugin.py
CHANGED
|
@@ -2006,6 +2006,16 @@ def _apply_asset_install(
|
|
|
2006
2006
|
"inode": version.inode,
|
|
2007
2007
|
}
|
|
2008
2008
|
print(f" Installed plugin {spec.kind}: {spec.path.name}")
|
|
2009
|
+
return _merge_asset_ownership(previous_ownership, name, editor, produced_entries)
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
def _merge_asset_ownership(
|
|
2013
|
+
previous_ownership: dict | None,
|
|
2014
|
+
name: str,
|
|
2015
|
+
editor: str,
|
|
2016
|
+
produced_entries: dict[str, dict],
|
|
2017
|
+
) -> dict:
|
|
2018
|
+
"""Fold one editor's freshly written assets into the pack's ownership record."""
|
|
2009
2019
|
entries = _asset_entries(previous_ownership, name)
|
|
2010
2020
|
entries.update(produced_entries)
|
|
2011
2021
|
consumers = _shared_asset_consumers(previous_ownership, name)
|
|
@@ -2022,6 +2032,37 @@ def _apply_asset_install(
|
|
|
2022
2032
|
}
|
|
2023
2033
|
|
|
2024
2034
|
|
|
2035
|
+
def _ownership_from_copied_assets(
|
|
2036
|
+
name: str,
|
|
2037
|
+
editor: str,
|
|
2038
|
+
pack_dir: Path,
|
|
2039
|
+
hook_specs: list[dict],
|
|
2040
|
+
previous_ownership: dict | None,
|
|
2041
|
+
) -> dict:
|
|
2042
|
+
"""Record what the non-transactional Claude/Codex install just copied.
|
|
2043
|
+
|
|
2044
|
+
``_copy_plugin_hook_scripts`` and ``_copy_plugin_scripts`` write the same
|
|
2045
|
+
paths ``_prepare_asset_specs`` describes but never recorded them, so
|
|
2046
|
+
removal had nothing to verify against and preserved every file as
|
|
2047
|
+
"untracked" (v4.32.1). Reading the versions back from disk after the copy
|
|
2048
|
+
gives removal the same sha256/mode/inode contract the JSON runtimes get.
|
|
2049
|
+
"""
|
|
2050
|
+
produced: dict[str, dict] = {}
|
|
2051
|
+
for spec in _prepare_asset_specs(name, pack_dir, hook_specs):
|
|
2052
|
+
version = _read_file_version(spec.path)
|
|
2053
|
+
if version is None:
|
|
2054
|
+
continue
|
|
2055
|
+
produced[spec.key] = {
|
|
2056
|
+
"path": str(spec.path),
|
|
2057
|
+
"kind": spec.kind,
|
|
2058
|
+
"sha256": hashlib.sha256(version.content).hexdigest(),
|
|
2059
|
+
"mode": version.mode,
|
|
2060
|
+
"device": version.device,
|
|
2061
|
+
"inode": version.inode,
|
|
2062
|
+
}
|
|
2063
|
+
return _merge_asset_ownership(previous_ownership, name, editor, produced)
|
|
2064
|
+
|
|
2065
|
+
|
|
2025
2066
|
def _preflight_asset_removal(
|
|
2026
2067
|
transaction: PluginFileTransaction,
|
|
2027
2068
|
ownership: dict | None,
|
|
@@ -2075,6 +2116,74 @@ def _apply_asset_removal(
|
|
|
2075
2116
|
print(f" WARN preserved changed or user-owned plugin asset: {path}")
|
|
2076
2117
|
|
|
2077
2118
|
|
|
2119
|
+
def _remove_owned_plugin_assets(state: dict | None, name: str, editor: str) -> None:
|
|
2120
|
+
"""Delete the hook and script files this pack installed for ``editor``.
|
|
2121
|
+
|
|
2122
|
+
Claude and Codex removal never consulted ``shared_asset_ownership``: the
|
|
2123
|
+
files were written on install, recorded, and then reported as "untracked"
|
|
2124
|
+
on remove because nothing looked the record up. Every pack left its hooks
|
|
2125
|
+
and ``plugin-scripts/<name>/`` behind (v4.32.1).
|
|
2126
|
+
|
|
2127
|
+
Same rules as the transactional runtimes: an entry is deleted only when
|
|
2128
|
+
this editor consumes it, no other editor still does, and the file on disk
|
|
2129
|
+
is byte- and inode-identical to what install recorded. Anything else
|
|
2130
|
+
(user edits, a file install never wrote, a symlink) is preserved and named.
|
|
2131
|
+
"""
|
|
2132
|
+
ownership = _shared_asset_ownership_for(state or {}, name)
|
|
2133
|
+
entries = _asset_entries(ownership, name)
|
|
2134
|
+
consumers = _shared_asset_consumers(ownership, name)
|
|
2135
|
+
editor_keys = set(consumers.get(editor, ()))
|
|
2136
|
+
other_keys = {
|
|
2137
|
+
key
|
|
2138
|
+
for consumer, keys in consumers.items()
|
|
2139
|
+
if consumer != editor
|
|
2140
|
+
for key in keys
|
|
2141
|
+
}
|
|
2142
|
+
scripts_root = TOOLKIT_DATA_DIR / "plugin-scripts" / name
|
|
2143
|
+
hook_prefix = f"plugin-{name}-"
|
|
2144
|
+
handled: set[Path] = set()
|
|
2145
|
+
|
|
2146
|
+
for key, entry in entries.items():
|
|
2147
|
+
raw_path = entry.get("path")
|
|
2148
|
+
if not isinstance(raw_path, str):
|
|
2149
|
+
continue
|
|
2150
|
+
path = Path(raw_path)
|
|
2151
|
+
handled.add(path.absolute())
|
|
2152
|
+
if key not in editor_keys or key in other_keys:
|
|
2153
|
+
continue
|
|
2154
|
+
kind = entry.get("kind")
|
|
2155
|
+
if kind == "hook":
|
|
2156
|
+
safe = path.parent.absolute() == HOOKS_DIR.absolute() and path.name.startswith(hook_prefix)
|
|
2157
|
+
elif kind == "script":
|
|
2158
|
+
safe = path.parent.absolute() == scripts_root.absolute()
|
|
2159
|
+
else:
|
|
2160
|
+
safe = False
|
|
2161
|
+
if not safe:
|
|
2162
|
+
print(f" WARN refused to remove plugin asset outside its owned root: {path}")
|
|
2163
|
+
continue
|
|
2164
|
+
if path.is_symlink():
|
|
2165
|
+
print(f" WARN preserved symlinked plugin asset: {path}")
|
|
2166
|
+
continue
|
|
2167
|
+
version = _read_file_version(path)
|
|
2168
|
+
if version is None:
|
|
2169
|
+
continue
|
|
2170
|
+
if not _asset_entry_matches(path, version, entry):
|
|
2171
|
+
print(f" WARN preserved changed plugin asset: {path}")
|
|
2172
|
+
continue
|
|
2173
|
+
path.unlink()
|
|
2174
|
+
print(f" Removed plugin asset: {path.name}")
|
|
2175
|
+
|
|
2176
|
+
for hook in sorted(HOOKS_DIR.glob(f"{hook_prefix}*")):
|
|
2177
|
+
if hook.absolute() not in handled:
|
|
2178
|
+
print(f" WARN preserved untracked plugin hook: {hook}")
|
|
2179
|
+
if scripts_root.is_dir():
|
|
2180
|
+
for leftover in sorted(scripts_root.iterdir()):
|
|
2181
|
+
if leftover.absolute() not in handled:
|
|
2182
|
+
print(f" WARN preserved untracked plugin script: {leftover}")
|
|
2183
|
+
if not any(scripts_root.iterdir()):
|
|
2184
|
+
scripts_root.rmdir()
|
|
2185
|
+
|
|
2186
|
+
|
|
2078
2187
|
def _state_after_removal(
|
|
2079
2188
|
state: dict,
|
|
2080
2189
|
editor: str,
|
|
@@ -2616,19 +2725,15 @@ def _remove_claude_pack_links(pack: dict, pack_dir: Path) -> None:
|
|
|
2616
2725
|
|
|
2617
2726
|
|
|
2618
2727
|
def remove_pack_claude(
|
|
2619
|
-
name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
|
|
2728
|
+
name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool,
|
|
2729
|
+
state: dict | None = None,
|
|
2620
2730
|
) -> bool:
|
|
2621
2731
|
hook_specs = _resolve_pack_hooks(pack, pack_dir)
|
|
2622
2732
|
rule_specs = _resolve_pack_rules(pack, pack_dir)
|
|
2623
2733
|
_remove_claude_pack_links(pack, pack_dir)
|
|
2624
2734
|
|
|
2625
2735
|
if not keep_shared_assets:
|
|
2626
|
-
|
|
2627
|
-
print(f" WARN preserved untracked plugin hook: {hook}")
|
|
2628
|
-
|
|
2629
|
-
scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
|
|
2630
|
-
if scripts_dir.is_dir():
|
|
2631
|
-
print(f" WARN preserved untracked plugin scripts: {scripts_dir}")
|
|
2736
|
+
_remove_owned_plugin_assets(state, name, "claude")
|
|
2632
2737
|
|
|
2633
2738
|
if any(not spec["is_core"] for spec in hook_specs):
|
|
2634
2739
|
_strip_claude_hooks(name)
|
|
@@ -3000,7 +3105,8 @@ def install_pack_codex(name: str, pack: dict, pack_dir: Path) -> bool:
|
|
|
3000
3105
|
|
|
3001
3106
|
|
|
3002
3107
|
def remove_pack_codex(
|
|
3003
|
-
name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
|
|
3108
|
+
name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool,
|
|
3109
|
+
state: dict | None = None,
|
|
3004
3110
|
) -> bool:
|
|
3005
3111
|
_assert_safe_codex_surface()
|
|
3006
3112
|
_strip_codex_hooks(name)
|
|
@@ -3016,14 +3122,10 @@ def remove_pack_codex(
|
|
|
3016
3122
|
print(f" WARN preserved user-owned Codex hook: {hook.name}")
|
|
3017
3123
|
|
|
3018
3124
|
if not keep_shared_assets:
|
|
3019
|
-
#
|
|
3020
|
-
#
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
|
|
3025
|
-
if scripts_dir.is_dir():
|
|
3026
|
-
print(f" WARN preserved untracked plugin scripts: {scripts_dir}")
|
|
3125
|
+
# Paths used by releases before native Codex plugin assets moved under
|
|
3126
|
+
# $CODEX_HOME. Claude still owns these when installed for both, which
|
|
3127
|
+
# the ownership consumers encode; only Codex-consumed entries go.
|
|
3128
|
+
_remove_owned_plugin_assets(state, name, "codex")
|
|
3027
3129
|
|
|
3028
3130
|
_remove_codex_rules(name)
|
|
3029
3131
|
print(f" Done: removed {name} from codex")
|
|
@@ -3202,6 +3304,14 @@ def _install_pack_locked(
|
|
|
3202
3304
|
if transaction_snapshots is not None:
|
|
3203
3305
|
_restore_file_snapshots(transaction_snapshots)
|
|
3204
3306
|
return False
|
|
3307
|
+
if editor in ("claude", "codex"):
|
|
3308
|
+
asset_ownership = _ownership_from_copied_assets(
|
|
3309
|
+
name,
|
|
3310
|
+
editor,
|
|
3311
|
+
pack_dir,
|
|
3312
|
+
_resolve_pack_hooks(pack, pack_dir),
|
|
3313
|
+
_shared_asset_ownership_for(state, name),
|
|
3314
|
+
)
|
|
3205
3315
|
|
|
3206
3316
|
if hook_config_update is not None:
|
|
3207
3317
|
if transaction_snapshots is not None:
|
|
@@ -3362,6 +3472,7 @@ def _remove_pack_locked(
|
|
|
3362
3472
|
pack,
|
|
3363
3473
|
pack_dir,
|
|
3364
3474
|
keep_shared_assets=keep_shared_assets,
|
|
3475
|
+
state=state,
|
|
3365
3476
|
)
|
|
3366
3477
|
elif editor == "codex":
|
|
3367
3478
|
ok = remove_pack_codex(
|
|
@@ -3369,6 +3480,7 @@ def _remove_pack_locked(
|
|
|
3369
3480
|
pack,
|
|
3370
3481
|
pack_dir,
|
|
3371
3482
|
keep_shared_assets=keep_shared_assets,
|
|
3483
|
+
state=state,
|
|
3372
3484
|
)
|
|
3373
3485
|
elif editor in JSON_HOOK_RUNTIMES:
|
|
3374
3486
|
ok = remove_pack_json_runtime(
|
|
@@ -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
|
|
|
@@ -152,16 +153,29 @@ MAX_EMITTED_SKILL_NODES = 10_000
|
|
|
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")):
|