@softspark/ai-toolkit 4.29.2 → 4.30.3
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 +105 -0
- package/README.md +40 -15
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/ARCHITECTURE.md +2 -2
- package/app/mcp-templates/README.md +7 -2
- package/app/mcp-templates/rag-mcp-legal.json +11 -0
- package/app/mcp-templates/rag-mcp.json +11 -0
- package/app/surface.json +1 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +29 -17
- package/bin/ai-toolkit.js +8 -0
- package/kb/history/completed/dsh-integration-plan-superseded.md +322 -0
- package/kb/history/completed/dsh-native-install-target-plan.md +331 -0
- package/kb/procedures/ecosystem-sync-sop.md +7 -5
- package/kb/procedures/maintenance-sop.md +1 -1
- package/kb/procedures/release-preparation-sop.md +81 -20
- package/kb/procedures/release-verification-sop.md +35 -5
- package/kb/reference/architecture-overview.md +24 -5
- package/kb/reference/cli-reference.md +1 -1
- package/kb/reference/dsh-compatibility.md +183 -0
- package/kb/reference/manifest-install.md +112 -5
- package/kb/reference/mcp-templates.md +11 -4
- package/kb/reference/plugin-pack-conventions.md +35 -18
- package/kb/reference/supported-tools-registry.md +30 -6
- package/llms-full.txt +1190 -69
- package/llms.txt +3 -0
- package/manifest.json +2 -2
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +673 -34
- package/scripts/config_resolver.py +80 -14
- package/scripts/doctor.py +98 -20
- package/scripts/ecosystem_tools.json +51 -1
- package/scripts/generate_codex_skills.py +22 -20
- package/scripts/install.py +30 -13
- package/scripts/install_steps/ai_tools.py +97 -33
- package/scripts/install_steps/dsh.py +5063 -0
- package/scripts/install_steps/install_state.py +1645 -57
- package/scripts/mcp_editors.py +5 -2
- package/scripts/plugin.py +2495 -163
- package/scripts/plugin_mcp.py +279 -0
- package/scripts/plugin_rules.py +389 -0
- package/scripts/plugin_schema.py +139 -23
- package/scripts/uninstall.py +47 -4
- package/scripts/validate.py +421 -0
package/scripts/validate.py
CHANGED
|
@@ -22,6 +22,7 @@ import json
|
|
|
22
22
|
import re
|
|
23
23
|
import sys
|
|
24
24
|
import tempfile
|
|
25
|
+
from itertools import islice
|
|
25
26
|
from pathlib import Path
|
|
26
27
|
|
|
27
28
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
@@ -130,6 +131,14 @@ VALID_KB_CATEGORIES = frozenset({
|
|
|
130
131
|
"decisions", "runbooks", "planning", "business", "templates",
|
|
131
132
|
})
|
|
132
133
|
|
|
134
|
+
SKILL_DIRECTORY_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
135
|
+
INVOCATION_BOOLEAN_FIELDS = ("user-invocable", "disable-model-invocation")
|
|
136
|
+
CAMEL_CASE_INVOCATION_FIELDS = ("userInvocable", "disableModelInvocation")
|
|
137
|
+
INVOCATION_BOOLEAN_VALUES = frozenset({
|
|
138
|
+
"true", "false", "yes", "no", "on", "off", "1", "0",
|
|
139
|
+
})
|
|
140
|
+
MAX_EMITTED_SKILL_NODES = 10_000
|
|
141
|
+
|
|
133
142
|
# Skill body budget, in bytes after the frontmatter block.
|
|
134
143
|
#
|
|
135
144
|
# The body loads in full every time a trigger matches, including when it matches
|
|
@@ -247,6 +256,268 @@ def _fm_has(lines: list[str], field: str) -> bool:
|
|
|
247
256
|
return any(line.startswith(f"{field}:") for line in lines)
|
|
248
257
|
|
|
249
258
|
|
|
259
|
+
def _validate_invocation_metadata(label: str, fm_lines: list[str],
|
|
260
|
+
vr: ValidationResult) -> None:
|
|
261
|
+
"""Reject metadata spellings that DSH interprets differently or ignores."""
|
|
262
|
+
entries: list[tuple[str, str]] = []
|
|
263
|
+
for line_number, line in enumerate(fm_lines, start=2):
|
|
264
|
+
if not line.strip() or line.lstrip().startswith("#") or line[0].isspace():
|
|
265
|
+
continue
|
|
266
|
+
parsed = _parse_supported_top_level_entry(
|
|
267
|
+
line,
|
|
268
|
+
label=label,
|
|
269
|
+
line_number=line_number,
|
|
270
|
+
vr=vr,
|
|
271
|
+
)
|
|
272
|
+
if parsed is not None:
|
|
273
|
+
entries.append(parsed)
|
|
274
|
+
|
|
275
|
+
for field in CAMEL_CASE_INVOCATION_FIELDS:
|
|
276
|
+
if any(key == field for key, _ in entries):
|
|
277
|
+
vr.error(
|
|
278
|
+
f"{label}: camel-case field '{field}' is forbidden; "
|
|
279
|
+
"use kebab-case invocation metadata"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
for field in INVOCATION_BOOLEAN_FIELDS:
|
|
283
|
+
occurrences = sum(1 for key, _ in entries if key == field)
|
|
284
|
+
if occurrences > 1:
|
|
285
|
+
vr.error(
|
|
286
|
+
f"{label}: duplicate canonical invocation key '{field}'"
|
|
287
|
+
)
|
|
288
|
+
for key, raw_value in entries:
|
|
289
|
+
if key != field:
|
|
290
|
+
continue
|
|
291
|
+
value = _frontmatter_scalar(raw_value).lower()
|
|
292
|
+
if value not in INVOCATION_BOOLEAN_VALUES:
|
|
293
|
+
vr.error(
|
|
294
|
+
f"{label}: field '{field}' has invalid boolean value"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _frontmatter_scalar(raw_value: str) -> str:
|
|
299
|
+
"""Decode the simple scalar forms used by emitted skill metadata."""
|
|
300
|
+
value = raw_value.strip()
|
|
301
|
+
if not value:
|
|
302
|
+
return ""
|
|
303
|
+
quoted = re.fullmatch(r'''(["'])(.*?)\1(?:\s+#.*)?''', value)
|
|
304
|
+
if quoted:
|
|
305
|
+
return quoted.group(2).strip()
|
|
306
|
+
return re.split(r"\s+#", value, maxsplit=1)[0].strip()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _parse_supported_top_level_entry(
|
|
310
|
+
line: str,
|
|
311
|
+
*,
|
|
312
|
+
label: str,
|
|
313
|
+
line_number: int,
|
|
314
|
+
vr: ValidationResult,
|
|
315
|
+
) -> tuple[str, str] | None:
|
|
316
|
+
"""Parse one scalar-mapping entry without YAML indirection features."""
|
|
317
|
+
if line.startswith(("'", '"')):
|
|
318
|
+
vr.error(
|
|
319
|
+
f"{label}:{line_number} - quoted frontmatter keys are unsupported"
|
|
320
|
+
)
|
|
321
|
+
return None
|
|
322
|
+
if re.match(r"^<<\s*:", line):
|
|
323
|
+
vr.error(f"{label}:{line_number} - YAML merge key '<<' is unsupported")
|
|
324
|
+
return None
|
|
325
|
+
if re.match(r"^[A-Za-z][A-Za-z0-9-]*\s+:", line):
|
|
326
|
+
vr.error(f"{label}:{line_number} - whitespace before ':' is unsupported")
|
|
327
|
+
return None
|
|
328
|
+
match = re.fullmatch(r"(?P<key>[A-Za-z][A-Za-z0-9-]*):(?P<value>.*)", line)
|
|
329
|
+
if match is None:
|
|
330
|
+
feature = "YAML anchors" if line.startswith("&") else "YAML aliases"
|
|
331
|
+
if line.startswith(("&", "*")):
|
|
332
|
+
vr.error(f"{label}:{line_number} - {feature} are unsupported")
|
|
333
|
+
else:
|
|
334
|
+
vr.error(
|
|
335
|
+
f"{label}:{line_number} - unsupported top-level key syntax; "
|
|
336
|
+
"use an unquoted key immediately followed by ':'"
|
|
337
|
+
)
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
raw_value = match.group("value")
|
|
341
|
+
value = raw_value.strip()
|
|
342
|
+
if value.startswith("&"):
|
|
343
|
+
vr.error(f"{label}:{line_number} - YAML anchors are unsupported")
|
|
344
|
+
return None
|
|
345
|
+
if value.startswith("*"):
|
|
346
|
+
vr.error(f"{label}:{line_number} - YAML aliases are unsupported")
|
|
347
|
+
return None
|
|
348
|
+
return match.group("key"), raw_value
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _emitted_quoted_scalar(
|
|
352
|
+
value: str,
|
|
353
|
+
*,
|
|
354
|
+
quote: str,
|
|
355
|
+
label: str,
|
|
356
|
+
line_number: int,
|
|
357
|
+
vr: ValidationResult,
|
|
358
|
+
) -> str | None:
|
|
359
|
+
"""Decode one supported quoted scalar and reject trailing YAML nodes."""
|
|
360
|
+
characters: list[str] = []
|
|
361
|
+
closing_index: int | None = None
|
|
362
|
+
index = 1
|
|
363
|
+
while index < len(value):
|
|
364
|
+
character = value[index]
|
|
365
|
+
if quote == "'" and character == "'" and index + 1 < len(value):
|
|
366
|
+
if value[index + 1] == "'":
|
|
367
|
+
characters.append("'")
|
|
368
|
+
index += 2
|
|
369
|
+
continue
|
|
370
|
+
if quote == '"' and character == "\\" and index + 1 < len(value):
|
|
371
|
+
characters.extend((character, value[index + 1]))
|
|
372
|
+
index += 2
|
|
373
|
+
continue
|
|
374
|
+
if character == quote:
|
|
375
|
+
closing_index = index
|
|
376
|
+
break
|
|
377
|
+
characters.append(character)
|
|
378
|
+
index += 1
|
|
379
|
+
|
|
380
|
+
quote_name = "single" if quote == "'" else "double"
|
|
381
|
+
if closing_index is None:
|
|
382
|
+
vr.error(
|
|
383
|
+
f"{label}:{line_number} - unterminated {quote_name}-quoted scalar"
|
|
384
|
+
)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
trailing = value[closing_index + 1:].strip()
|
|
388
|
+
if trailing and not trailing.startswith("#"):
|
|
389
|
+
vr.error(
|
|
390
|
+
f"{label}:{line_number} - unsupported content after quoted scalar"
|
|
391
|
+
)
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
if quote == "'":
|
|
395
|
+
return "".join(characters).strip()
|
|
396
|
+
token = value[:closing_index + 1]
|
|
397
|
+
try:
|
|
398
|
+
decoded = json.loads(token)
|
|
399
|
+
except json.JSONDecodeError:
|
|
400
|
+
vr.error(f"{label}:{line_number} - invalid double-quoted scalar")
|
|
401
|
+
return None
|
|
402
|
+
return decoded.strip()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _parse_emitted_scalar(
|
|
406
|
+
raw_value: str,
|
|
407
|
+
*,
|
|
408
|
+
label: str,
|
|
409
|
+
line_number: int,
|
|
410
|
+
vr: ValidationResult,
|
|
411
|
+
) -> str | None:
|
|
412
|
+
"""Parse the supported single-line scalar subset of emitted YAML."""
|
|
413
|
+
value = raw_value.strip()
|
|
414
|
+
if not value:
|
|
415
|
+
return ""
|
|
416
|
+
if value.startswith(("|", ">")):
|
|
417
|
+
vr.error(f"{label}:{line_number} - unsupported block scalar")
|
|
418
|
+
return None
|
|
419
|
+
if value.startswith(("[", "{")) or value == "-" or value.startswith("- "):
|
|
420
|
+
vr.error(f"{label}:{line_number} - unsupported collection value")
|
|
421
|
+
return None
|
|
422
|
+
if value[0] in {"'", '"'}:
|
|
423
|
+
return _emitted_quoted_scalar(
|
|
424
|
+
value,
|
|
425
|
+
quote=value[0],
|
|
426
|
+
label=label,
|
|
427
|
+
line_number=line_number,
|
|
428
|
+
vr=vr,
|
|
429
|
+
)
|
|
430
|
+
return re.split(r"\s+#", value, maxsplit=1)[0].strip()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _parse_emitted_frontmatter(
|
|
434
|
+
skill_file: Path,
|
|
435
|
+
label: str,
|
|
436
|
+
vr: ValidationResult,
|
|
437
|
+
) -> dict[str, str] | None:
|
|
438
|
+
"""Parse a complete top-level frontmatter mapping and reject ambiguity."""
|
|
439
|
+
try:
|
|
440
|
+
lines = skill_file.read_text(encoding="utf-8").splitlines()
|
|
441
|
+
except (OSError, UnicodeError) as error:
|
|
442
|
+
vr.error(f"{label} - cannot read SKILL.md: {error}")
|
|
443
|
+
return None
|
|
444
|
+
if not lines or lines[0] != "---":
|
|
445
|
+
vr.error(f"{label} - missing opening frontmatter delimiter")
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
closing_index = next(
|
|
449
|
+
(index for index, line in enumerate(lines[1:], start=1)
|
|
450
|
+
if line == "---"),
|
|
451
|
+
None,
|
|
452
|
+
)
|
|
453
|
+
if closing_index is None:
|
|
454
|
+
vr.error(f"{label} - missing closing frontmatter delimiter")
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
fields: dict[str, str] = {}
|
|
458
|
+
seen_keys: set[str] = set()
|
|
459
|
+
for line_number, line in enumerate(lines[1:closing_index], start=2):
|
|
460
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
461
|
+
continue
|
|
462
|
+
if line[0].isspace():
|
|
463
|
+
vr.error(f"{label}:{line_number} - unexpected indentation")
|
|
464
|
+
continue
|
|
465
|
+
parsed = _parse_supported_top_level_entry(
|
|
466
|
+
line,
|
|
467
|
+
label=label,
|
|
468
|
+
line_number=line_number,
|
|
469
|
+
vr=vr,
|
|
470
|
+
)
|
|
471
|
+
if parsed is None:
|
|
472
|
+
continue
|
|
473
|
+
key, raw_value = parsed
|
|
474
|
+
if key in seen_keys:
|
|
475
|
+
vr.error(f"{label}:{line_number} - duplicate top-level key '{key}'")
|
|
476
|
+
continue
|
|
477
|
+
seen_keys.add(key)
|
|
478
|
+
value = _parse_emitted_scalar(
|
|
479
|
+
raw_value,
|
|
480
|
+
label=label,
|
|
481
|
+
line_number=line_number,
|
|
482
|
+
vr=vr,
|
|
483
|
+
)
|
|
484
|
+
if value is not None:
|
|
485
|
+
fields[key] = value
|
|
486
|
+
return fields
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _validate_emitted_frontmatter(
|
|
490
|
+
entry: Path,
|
|
491
|
+
fields: dict[str, str],
|
|
492
|
+
label: str,
|
|
493
|
+
vr: ValidationResult,
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Validate the DSH Agent Skills frontmatter contract."""
|
|
496
|
+
for field in CAMEL_CASE_INVOCATION_FIELDS:
|
|
497
|
+
if field in fields:
|
|
498
|
+
vr.error(
|
|
499
|
+
f"{label}: camel-case field '{field}' is forbidden; "
|
|
500
|
+
"use kebab-case invocation metadata"
|
|
501
|
+
)
|
|
502
|
+
for field in INVOCATION_BOOLEAN_FIELDS:
|
|
503
|
+
if field in fields and fields[field].lower() not in INVOCATION_BOOLEAN_VALUES:
|
|
504
|
+
vr.error(f"{label}: field '{field}' has invalid boolean value")
|
|
505
|
+
|
|
506
|
+
name = fields.get("name", "").strip()
|
|
507
|
+
description = fields.get("description", "").strip()
|
|
508
|
+
if not name:
|
|
509
|
+
vr.error(f"{label} - required field 'name' must be non-empty")
|
|
510
|
+
elif not SKILL_DIRECTORY_NAME.fullmatch(name):
|
|
511
|
+
vr.error(f"{label} - name must be kebab-case")
|
|
512
|
+
elif name != entry.name:
|
|
513
|
+
vr.error(
|
|
514
|
+
f"{label} - declared name '{name}' does not match directory "
|
|
515
|
+
f"'{entry.name}'"
|
|
516
|
+
)
|
|
517
|
+
if not description:
|
|
518
|
+
vr.error(f"{label} - required field 'description' must be non-empty")
|
|
519
|
+
|
|
520
|
+
|
|
250
521
|
def _body_line_count(filepath: Path) -> int:
|
|
251
522
|
"""Count lines after the second --- delimiter."""
|
|
252
523
|
count = 0
|
|
@@ -368,6 +639,11 @@ def _validate_skill_frontmatter(tk_dir: Path, skill_path: Path,
|
|
|
368
639
|
name = skill_path.name
|
|
369
640
|
skill_file = skill_path / "SKILL.md"
|
|
370
641
|
|
|
642
|
+
if not SKILL_DIRECTORY_NAME.fullmatch(name):
|
|
643
|
+
vr.error(f"skills/{name} - invalid skill directory name")
|
|
644
|
+
|
|
645
|
+
_validate_invocation_metadata(f"skills/{name}/SKILL.md", fm_lines, vr)
|
|
646
|
+
|
|
371
647
|
if not _fm_has(fm_lines, "name"):
|
|
372
648
|
vr.error(f"{name} - Missing name field")
|
|
373
649
|
if not _fm_has(fm_lines, "description"):
|
|
@@ -553,6 +829,150 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
|
|
|
553
829
|
return skill_count
|
|
554
830
|
|
|
555
831
|
|
|
832
|
+
def _validate_emitted_skill_layout(
|
|
833
|
+
entry: Path,
|
|
834
|
+
label: str,
|
|
835
|
+
vr: ValidationResult,
|
|
836
|
+
) -> None:
|
|
837
|
+
"""Reject hidden skills and unsafe links within one emitted bundle."""
|
|
838
|
+
try:
|
|
839
|
+
bundle_root = entry.resolve(strict=True)
|
|
840
|
+
except RuntimeError:
|
|
841
|
+
vr.error(f"{label} - symlink cycle detected at bundle root")
|
|
842
|
+
return
|
|
843
|
+
except OSError as error:
|
|
844
|
+
vr.error(f"{label} - cannot resolve emitted skill bundle: {error}")
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
visited = {bundle_root}
|
|
848
|
+
pending = [(entry, frozenset({bundle_root}))]
|
|
849
|
+
observed_nodes = 0
|
|
850
|
+
|
|
851
|
+
while pending:
|
|
852
|
+
logical_dir, ancestors = pending.pop()
|
|
853
|
+
try:
|
|
854
|
+
remaining = MAX_EMITTED_SKILL_NODES - observed_nodes
|
|
855
|
+
children, is_overflow = _bounded_sorted_directory(
|
|
856
|
+
logical_dir,
|
|
857
|
+
remaining,
|
|
858
|
+
)
|
|
859
|
+
except OSError as error:
|
|
860
|
+
relative = logical_dir.relative_to(entry)
|
|
861
|
+
location = label if relative == Path(".") else f"{label}/{relative}"
|
|
862
|
+
vr.error(f"{location} - cannot inspect skill bundle: {error}")
|
|
863
|
+
continue
|
|
864
|
+
if is_overflow:
|
|
865
|
+
vr.error(
|
|
866
|
+
f"{label} - traversal exceeded {MAX_EMITTED_SKILL_NODES} "
|
|
867
|
+
"entries; reduce the emitted skill bundle or remove cycles"
|
|
868
|
+
)
|
|
869
|
+
return
|
|
870
|
+
|
|
871
|
+
for child in children:
|
|
872
|
+
observed_nodes += 1
|
|
873
|
+
|
|
874
|
+
relative = child.relative_to(entry)
|
|
875
|
+
child_label = f"{label}/{relative.as_posix()}"
|
|
876
|
+
is_symlink = child.is_symlink()
|
|
877
|
+
if child.name == "SKILL.md" and relative != Path("SKILL.md"):
|
|
878
|
+
vr.error(
|
|
879
|
+
f"{child_label} - nested SKILL.md is not discoverable; "
|
|
880
|
+
"place it at .agents/skills/<name>/SKILL.md"
|
|
881
|
+
)
|
|
882
|
+
try:
|
|
883
|
+
resolved_child = child.resolve(strict=True)
|
|
884
|
+
except RuntimeError:
|
|
885
|
+
vr.error(
|
|
886
|
+
f"{child_label} - symlink cycle detected; remove the "
|
|
887
|
+
"cycle from the emitted skill bundle"
|
|
888
|
+
)
|
|
889
|
+
continue
|
|
890
|
+
except OSError as error:
|
|
891
|
+
kind = "symlink" if is_symlink else "path"
|
|
892
|
+
vr.error(f"{child_label} - cannot resolve {kind}: {error}")
|
|
893
|
+
continue
|
|
894
|
+
|
|
895
|
+
if is_symlink:
|
|
896
|
+
try:
|
|
897
|
+
resolved_child.relative_to(bundle_root)
|
|
898
|
+
except ValueError:
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
if not resolved_child.is_dir():
|
|
902
|
+
continue
|
|
903
|
+
if resolved_child in ancestors:
|
|
904
|
+
vr.error(
|
|
905
|
+
f"{child_label} - symlink cycle detected; remove the "
|
|
906
|
+
"cycle from the emitted skill bundle"
|
|
907
|
+
)
|
|
908
|
+
continue
|
|
909
|
+
if resolved_child in visited:
|
|
910
|
+
continue
|
|
911
|
+
visited.add(resolved_child)
|
|
912
|
+
pending.append((child, ancestors | {resolved_child}))
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _bounded_sorted_directory(
|
|
916
|
+
directory: Path,
|
|
917
|
+
limit: int,
|
|
918
|
+
) -> tuple[list[Path], bool]:
|
|
919
|
+
"""Materialize at most ``limit + 1`` children before sorting."""
|
|
920
|
+
children = list(islice(directory.iterdir(), limit + 1))
|
|
921
|
+
is_overflow = len(children) > limit
|
|
922
|
+
if is_overflow:
|
|
923
|
+
children.pop()
|
|
924
|
+
children.sort(key=lambda path: path.name)
|
|
925
|
+
return children, is_overflow
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def validate_emitted_agent_skills(tk_dir: Path, vr: ValidationResult) -> None:
|
|
929
|
+
"""Validate the one-level ``.agents/skills`` discovery contract."""
|
|
930
|
+
print("## Emitted Agent Skills")
|
|
931
|
+
skills_root = tk_dir / ".agents" / "skills"
|
|
932
|
+
if not skills_root.is_dir():
|
|
933
|
+
print(" SKIP: .agents/skills not present")
|
|
934
|
+
print()
|
|
935
|
+
return
|
|
936
|
+
|
|
937
|
+
try:
|
|
938
|
+
root_entries, is_overflow = _bounded_sorted_directory(
|
|
939
|
+
skills_root,
|
|
940
|
+
MAX_EMITTED_SKILL_NODES,
|
|
941
|
+
)
|
|
942
|
+
except OSError as error:
|
|
943
|
+
vr.error(f".agents/skills - cannot inspect skill root: {error}")
|
|
944
|
+
print()
|
|
945
|
+
return
|
|
946
|
+
if is_overflow:
|
|
947
|
+
vr.error(
|
|
948
|
+
f".agents/skills - directory exceeds {MAX_EMITTED_SKILL_NODES} "
|
|
949
|
+
"entries; reduce the emitted skill catalogue"
|
|
950
|
+
)
|
|
951
|
+
print()
|
|
952
|
+
return
|
|
953
|
+
entries = [
|
|
954
|
+
entry for entry in root_entries
|
|
955
|
+
if not entry.name.startswith(".") and (entry.is_dir() or entry.is_symlink())
|
|
956
|
+
]
|
|
957
|
+
for entry in entries:
|
|
958
|
+
label = f".agents/skills/{entry.name}"
|
|
959
|
+
if not SKILL_DIRECTORY_NAME.fullmatch(entry.name):
|
|
960
|
+
vr.error(f"{label} - invalid skill directory name")
|
|
961
|
+
|
|
962
|
+
_validate_emitted_skill_layout(entry, label, vr)
|
|
963
|
+
skill_file = entry / "SKILL.md"
|
|
964
|
+
if not skill_file.is_file():
|
|
965
|
+
vr.error(f"{label} - missing top-level SKILL.md")
|
|
966
|
+
continue
|
|
967
|
+
skill_label = f"{label}/SKILL.md"
|
|
968
|
+
fields = _parse_emitted_frontmatter(skill_file, skill_label, vr)
|
|
969
|
+
if fields is not None:
|
|
970
|
+
_validate_emitted_frontmatter(entry, fields, skill_label, vr)
|
|
971
|
+
|
|
972
|
+
print(f" Found: {len(entries)} emitted skill entries")
|
|
973
|
+
print()
|
|
974
|
+
|
|
975
|
+
|
|
556
976
|
def validate_legacy_commands(tk_dir: Path, vr: ValidationResult) -> None:
|
|
557
977
|
"""Check for legacy command files."""
|
|
558
978
|
commands_dir = tk_dir / "app" / "commands"
|
|
@@ -1251,6 +1671,7 @@ def _run_all_checks(tk_dir: Path, vr: ValidationResult) -> tuple[int, int, str]:
|
|
|
1251
1671
|
"""Run all validation checks. Returns (agent_count, skill_count, actual_tests)."""
|
|
1252
1672
|
agent_count = validate_agents(tk_dir, vr)
|
|
1253
1673
|
skill_count = validate_skills(tk_dir, vr)
|
|
1674
|
+
validate_emitted_agent_skills(tk_dir, vr)
|
|
1254
1675
|
validate_legacy_commands(tk_dir, vr)
|
|
1255
1676
|
validate_hook_events(tk_dir, vr)
|
|
1256
1677
|
validate_language_rules(tk_dir, vr)
|