bmad-method 6.11.1-next.9 → 6.12.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/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +12 -0
- package/CLAUDE.md +1 -0
- package/README.md +13 -19
- package/README_CN.md +0 -10
- package/README_KR.md +90 -0
- package/README_VN.md +0 -10
- package/greptile.json +52 -0
- package/package.json +6 -5
- package/src/bmm-skills/agents/bmad-agent-analyst/SKILL.md +1 -1
- package/src/bmm-skills/agents/bmad-agent-analyst/customize.toml +1 -3
- package/src/bmm-skills/agents/bmad-agent-architect/SKILL.md +1 -1
- package/src/bmm-skills/agents/bmad-agent-architect/customize.toml +1 -3
- package/src/bmm-skills/agents/bmad-agent-dev/SKILL.md +1 -1
- package/src/bmm-skills/agents/bmad-agent-dev/customize.toml +1 -3
- package/src/bmm-skills/agents/bmad-agent-pm/SKILL.md +1 -1
- package/src/bmm-skills/agents/bmad-agent-pm/customize.toml +1 -3
- package/src/bmm-skills/agents/bmad-agent-ux-designer/SKILL.md +1 -1
- package/src/bmm-skills/agents/bmad-agent-ux-designer/customize.toml +1 -3
- package/src/bmm-skills/module-help.csv +2 -2
- package/src/bmm-skills/plan/bmad-architecture/SKILL.md +1 -1
- package/src/bmm-skills/plan/bmad-architecture/customize.toml +5 -6
- package/src/bmm-skills/plan/bmad-create-epics-and-stories/SKILL.md +1 -1
- package/src/bmm-skills/plan/bmad-create-epics-and-stories/customize.toml +1 -3
- package/src/bmm-skills/plan/bmad-create-epics-and-stories/steps/step-04-final-validation.md +1 -1
- package/src/bmm-skills/plan/bmad-prd/SKILL.md +1 -1
- package/src/bmm-skills/plan/bmad-prd/customize.toml +5 -6
- package/src/bmm-skills/plan/bmad-prfaq/SKILL.md +1 -1
- package/src/bmm-skills/plan/bmad-prfaq/customize.toml +1 -3
- package/src/bmm-skills/plan/bmad-prfaq/references/verdict.md +1 -1
- package/src/bmm-skills/plan/bmad-product-brief/SKILL.md +1 -1
- package/src/bmm-skills/plan/bmad-product-brief/customize.toml +5 -6
- package/src/bmm-skills/plan/bmad-project-context/SKILL.md +7 -6
- package/src/bmm-skills/plan/bmad-project-context/references/best-practices.md +2 -2
- package/src/bmm-skills/plan/bmad-spec/SKILL.md +1 -1
- package/src/bmm-skills/plan/bmad-spec/customize.toml +5 -5
- package/src/bmm-skills/plan/bmad-sprint-planning/SKILL.md +1 -1
- package/src/bmm-skills/plan/bmad-sprint-planning/customize.toml +1 -3
- package/src/bmm-skills/plan/bmad-sprint-planning/scripts/__pycache__/sprint_plan.cpython-311.pyc +0 -0
- package/src/bmm-skills/plan/bmad-sprint-planning/scripts/tests/__pycache__/test_sprint_plan.cpython-311-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/plan/bmad-ux/SKILL.md +1 -1
- package/src/bmm-skills/plan/bmad-ux/customize.toml +1 -3
- package/src/bmm-skills/ship/bmad-build/SKILL.md +1 -1
- package/src/bmm-skills/ship/bmad-build/customize.toml +21 -33
- package/src/bmm-skills/ship/bmad-build/references/claims-check.md +14 -0
- package/src/bmm-skills/ship/bmad-build/review-prompts/edge-case-hunter.md +31 -7
- package/src/bmm-skills/ship/bmad-build/review-prompts/verification-gap.md +4 -4
- package/src/bmm-skills/ship/bmad-build/spec-template.md +26 -4
- package/src/bmm-skills/ship/bmad-build/step-01-clarify-and-route.md +21 -26
- package/src/bmm-skills/ship/bmad-build/step-02-plan.md +31 -16
- package/src/bmm-skills/ship/bmad-build/step-03-implement.md +5 -3
- package/src/bmm-skills/ship/bmad-build/step-04-review.md +49 -19
- package/src/bmm-skills/ship/bmad-build/step-05-present.md +9 -41
- package/src/bmm-skills/ship/bmad-build/step-oneshot.md +60 -29
- package/src/bmm-skills/ship/bmad-build/sync-sprint-status.md +5 -19
- package/src/bmm-skills/ship/bmad-build-auto/customize.toml +17 -19
- package/src/bmm-skills/ship/bmad-build-auto/references/claims-check.md +14 -0
- package/src/bmm-skills/ship/bmad-build-auto/review-prompts/edge-case-hunter.md +31 -7
- package/src/bmm-skills/ship/bmad-build-auto/review-prompts/verification-gap.md +4 -4
- package/src/bmm-skills/ship/bmad-build-auto/spec-template.md +4 -7
- package/src/bmm-skills/ship/bmad-build-auto/step-01-clarify-and-route.md +3 -2
- package/src/bmm-skills/ship/bmad-build-auto/step-03-implement.md +5 -1
- package/src/bmm-skills/ship/bmad-build-auto/step-04-review.md +58 -38
- package/src/bmm-skills/ship/bmad-build-auto/workflow.md +1 -1
- package/src/bmm-skills/ship/bmad-code-review/SKILL.md +1 -1
- package/src/bmm-skills/ship/bmad-code-review/customize.toml +15 -18
- package/src/bmm-skills/ship/bmad-code-review/references/claims-check.md +14 -0
- package/src/bmm-skills/ship/bmad-code-review/review-prompts/edge-case-hunter.md +31 -7
- package/src/bmm-skills/ship/bmad-code-review/review-prompts/verification-gap.md +4 -4
- package/src/bmm-skills/ship/bmad-code-review/steps/step-01-gather-context.md +30 -23
- package/src/bmm-skills/ship/bmad-code-review/steps/step-02-review.md +2 -2
- package/src/bmm-skills/ship/bmad-code-review/steps/step-03-triage.md +25 -21
- package/src/bmm-skills/ship/bmad-code-review/steps/step-04-present.md +20 -18
- package/src/bmm-skills/ship/bmad-correct-course/SKILL.md +13 -5
- package/src/bmm-skills/ship/bmad-correct-course/customize.toml +1 -3
- package/src/bmm-skills/ship/bmad-qa-generate-e2e-tests/SKILL.md +2 -2
- package/src/bmm-skills/ship/bmad-qa-generate-e2e-tests/customize.toml +1 -3
- package/src/bmm-skills/ship/bmad-retrospective/SKILL.md +1 -1
- package/src/bmm-skills/ship/bmad-retrospective/customize.toml +1 -3
- package/src/bmm-skills/ship/bmad-retrospective/scripts/__pycache__/sprint_status.cpython-311.pyc +0 -0
- package/src/bmm-skills/ship/bmad-retrospective/scripts/tests/__pycache__/test_git_evidence.cpython-311-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/ship/bmad-retrospective/scripts/tests/__pycache__/test_sprint_status.cpython-311-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/SKILL.md +4 -4
- package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/customize.toml +2 -4
- package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-05-wrapup.md +1 -1
- package/src/bmm-skills/v6-shims/README.md +1 -0
- package/src/bmm-skills/v6-shims/bmad-checkpoint-preview/SKILL.md +21 -0
- package/src/bmm-skills/v6-shims/bmad-create-architecture/SKILL.md +1 -1
- package/src/bmm-skills/v6-shims/bmad-create-architecture/customize.toml +1 -3
- package/src/bmm-skills/v6-shims/bmad-create-prd/SKILL.md +1 -1
- package/src/bmm-skills/v6-shims/bmad-create-prd/customize.toml +1 -3
- package/src/bmm-skills/v6-shims/bmad-create-story/SKILL.md +3 -3
- package/src/bmm-skills/v6-shims/bmad-create-story/customize.toml +1 -3
- package/src/bmm-skills/v6-shims/bmad-dev-story/SKILL.md +2 -2
- package/src/bmm-skills/v6-shims/bmad-dev-story/customize.toml +1 -6
- package/src/bmm-skills/v6-shims/bmad-domain-research/SKILL.md +1 -1
- package/src/bmm-skills/v6-shims/bmad-edit-prd/SKILL.md +1 -1
- package/src/bmm-skills/v6-shims/bmad-edit-prd/customize.toml +1 -3
- package/src/bmm-skills/v6-shims/bmad-market-research/SKILL.md +1 -1
- package/src/bmm-skills/v6-shims/bmad-sprint-status/SKILL.md +1 -1
- package/src/bmm-skills/v6-shims/bmad-sprint-status/customize.toml +1 -3
- package/src/bmm-skills/v6-shims/bmad-technical-research/SKILL.md +1 -1
- package/src/bmm-skills/v6-shims/bmad-validate-prd/SKILL.md +1 -1
- package/src/bmm-skills/v6-shims/bmad-validate-prd/customize.toml +1 -3
- package/src/core-skills/bmad-advanced-elicitation/SKILL.md +22 -23
- package/src/core-skills/bmad-brainstorming/SKILL.md +1 -1
- package/src/core-skills/bmad-brainstorming/customize.toml +5 -6
- package/src/core-skills/bmad-brainstorming/scripts/brain.py +19 -0
- package/src/core-skills/bmad-brainstorming/scripts/tests/test_brain.py +50 -0
- package/src/core-skills/bmad-customize/SKILL.md +1 -1
- package/src/core-skills/bmad-deep-recon/SKILL.md +1 -1
- package/src/core-skills/bmad-forge-idea/SKILL.md +1 -1
- package/src/core-skills/bmad-forge-idea/customize.toml +5 -6
- package/src/core-skills/bmad-forge-idea/scripts/resolve_personas.py +3 -1
- package/src/core-skills/bmad-forge-idea/scripts/tests/test_resolve_personas.py +22 -0
- package/src/core-skills/bmad-help/SKILL.md +1 -1
- package/src/core-skills/bmad-party-mode/SKILL.md +1 -1
- package/src/core-skills/bmad-party-mode/customize.toml +4 -4
- package/src/core-skills/bmad-party-mode/references/create-party.md +1 -1
- package/src/core-skills/bmad-party-mode/scripts/resolve_party.py +3 -1
- package/src/core-skills/bmad-party-mode/scripts/tests/test_resolve_party.py +22 -0
- package/src/core-skills/bmad-review/SKILL.md +5 -4
- package/src/core-skills/bmad-review/customize.toml +1 -1
- package/src/core-skills/bmad-review/references/lens-edge-case-hunter.md +18 -0
- package/src/core-skills/module-help.csv +1 -1
- package/src/scripts/__pycache__/config_utils.cpython-311.pyc +0 -0
- package/src/scripts/resolve_config.py +9 -1
- package/src/scripts/resolve_customization.py +72 -7
- package/src/scripts/tests/__pycache__/test_config_utils.cpython-311.pyc +0 -0
- package/src/scripts/tests/__pycache__/test_resolve_config.cpython-311.pyc +0 -0
- package/src/scripts/tests/__pycache__/test_resolve_customization.cpython-311.pyc +0 -0
- package/src/scripts/tests/test_resolve_config.py +28 -0
- package/src/scripts/tests/test_resolve_customization.py +102 -0
- package/tools/installer/core/installer.js +33 -1
- package/tools/installer/core/shim-policy.js +75 -7
- package/tools/installer/ide/platform-codes.yaml +13 -0
- package/tools/installer/prompts.js +15 -2
- package/tools/installer/ui.js +28 -8
- package/tools/skill-validator.md +85 -151
- package/tools/tests/__pycache__/test_validate_skills.cpython-311.pyc +0 -0
- package/tools/tests/fixtures/validate-skills/bmad/SKILL.md +8 -0
- package/tools/tests/fixtures/validate-skills/deprecated-shim/SKILL.md +9 -0
- package/tools/tests/fixtures/validate-skills/missing-trigger/SKILL.md +9 -0
- package/tools/tests/fixtures/validate-skills/with-trigger/SKILL.md +8 -0
- package/tools/tests/test_validate_skills.py +476 -0
- package/tools/validate-published-implementation-model.mjs +0 -9
- package/tools/validate_skills.py +698 -0
- package/tools/validate-skills.js +0 -735
- /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/generate-trail.md +0 -0
- /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-01-orientation.md +0 -0
- /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-02-walkthrough.md +0 -0
- /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-03-detail-pass.md +0 -0
- /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-04-testing.md +0 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# ///
|
|
5
|
+
"""Deterministic Skill Validator
|
|
6
|
+
|
|
7
|
+
Validates 10 deterministic rules across skill directories.
|
|
8
|
+
Acts as a fast first-pass complement to the inference-based skill validator.
|
|
9
|
+
|
|
10
|
+
What it checks:
|
|
11
|
+
- SKILL-01: SKILL.md exists
|
|
12
|
+
- SKILL-02: SKILL.md frontmatter has name
|
|
13
|
+
- SKILL-03: SKILL.md frontmatter has description
|
|
14
|
+
- SKILL-04: name format (lowercase, hyphens, no forbidden substrings)
|
|
15
|
+
- SKILL-05: name matches directory basename
|
|
16
|
+
- SKILL-06: description quality (length, "Use when"/"Use if")
|
|
17
|
+
- SKILL-07: SKILL.md has body content after frontmatter
|
|
18
|
+
- PATH-02: no installed_path variable
|
|
19
|
+
- SEQ-02: no time estimates
|
|
20
|
+
- TPL-01: template files must not contain compile-time {{.var}} substitutions
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
uv run --python 3.11 tools/validate_skills.py # All skills, human-readable
|
|
24
|
+
uv run --python 3.11 tools/validate_skills.py path/to/skill-dir # Single skill
|
|
25
|
+
uv run --python 3.11 tools/validate_skills.py --strict # Exit 1 on HIGH+ findings
|
|
26
|
+
uv run --python 3.11 tools/validate_skills.py --json # JSON output
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
sys.dont_write_bytecode = True
|
|
38
|
+
|
|
39
|
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
40
|
+
SRC_DIR = os.path.join(PROJECT_ROOT, "src")
|
|
41
|
+
|
|
42
|
+
# JS RegExp#toString() forms — Python's re.Pattern repr is not load-bearing for messages.
|
|
43
|
+
NAME_REGEX_DISPLAY = r"/^(?:bmad|bmad-[a-z0-9]+(?:-[a-z0-9]+)*)$/"
|
|
44
|
+
|
|
45
|
+
NAME_REGEX = re.compile(r"^(?:bmad|bmad-[a-z0-9]+(?:-[a-z0-9]+)*)$")
|
|
46
|
+
TIME_ESTIMATE_PATTERNS = [
|
|
47
|
+
re.compile(r"takes?\s+\d+\s*min", re.I),
|
|
48
|
+
re.compile(r"~\s*\d+\s*min", re.I),
|
|
49
|
+
re.compile(r"estimated\s+time", re.I),
|
|
50
|
+
re.compile(r"\bETA\b"),
|
|
51
|
+
]
|
|
52
|
+
TEMPLATE_FILENAME_REGEX = re.compile(r"template", re.I)
|
|
53
|
+
COMPILE_TIME_SUB_REGEX = re.compile(r"\{\{\.\w+\}\}")
|
|
54
|
+
INSTALLED_PATH_RE = re.compile(r"installed_path", re.I)
|
|
55
|
+
USE_WHEN_RE = re.compile(r"use\s+when\b", re.I)
|
|
56
|
+
USE_IF_RE = re.compile(r"use\s+if\b", re.I)
|
|
57
|
+
DEPRECATED_RE = re.compile(r"^\s*deprecated\b", re.I)
|
|
58
|
+
|
|
59
|
+
SKIP_DIRS = {"node_modules", ".git"}
|
|
60
|
+
SCAN_EXTENSIONS = {".md", ".yaml", ".yml"}
|
|
61
|
+
|
|
62
|
+
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- Output Escaping ---
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def escape_annotation(s: str) -> str:
|
|
69
|
+
return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def escape_table_cell(s: str) -> str:
|
|
73
|
+
return str(s).replace("|", "\\|")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _relpath(to_path: str, start: str) -> str:
|
|
77
|
+
rel = os.path.relpath(to_path, start)
|
|
78
|
+
return "" if rel == "." else rel
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _finding(
|
|
82
|
+
rule: str,
|
|
83
|
+
title: str,
|
|
84
|
+
severity: str,
|
|
85
|
+
file: str,
|
|
86
|
+
detail: str,
|
|
87
|
+
fix: str,
|
|
88
|
+
line: int | None = None,
|
|
89
|
+
) -> dict:
|
|
90
|
+
item = {
|
|
91
|
+
"rule": rule,
|
|
92
|
+
"title": title,
|
|
93
|
+
"severity": severity,
|
|
94
|
+
"file": file,
|
|
95
|
+
"detail": detail,
|
|
96
|
+
"fix": fix,
|
|
97
|
+
}
|
|
98
|
+
if line is not None:
|
|
99
|
+
item["line"] = line
|
|
100
|
+
return item
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- Frontmatter Parsing ---
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _frontmatter_block(content: str) -> str | None:
|
|
107
|
+
trimmed = content.lstrip()
|
|
108
|
+
if not trimmed.startswith("---"):
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
end_index = trimmed.find("\n---\n", 3)
|
|
112
|
+
if end_index == -1:
|
|
113
|
+
if trimmed.endswith("\n---"):
|
|
114
|
+
end_index = len(trimmed) - 4
|
|
115
|
+
else:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
return trimmed[3:end_index].strip()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _strip_quotes(value: str) -> str:
|
|
122
|
+
if (value.startswith("'") and value.endswith("'")) or (
|
|
123
|
+
value.startswith('"') and value.endswith('"')
|
|
124
|
+
):
|
|
125
|
+
return value[1:-1]
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_frontmatter(content: str) -> dict[str, str] | None:
|
|
130
|
+
fm_block = _frontmatter_block(content)
|
|
131
|
+
if fm_block is None:
|
|
132
|
+
return None
|
|
133
|
+
if fm_block == "":
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
result: dict[str, str] = {}
|
|
137
|
+
for line in fm_block.split("\n"):
|
|
138
|
+
colon_index = line.find(":")
|
|
139
|
+
if colon_index == -1:
|
|
140
|
+
continue
|
|
141
|
+
# Skip indented lines (nested YAML values)
|
|
142
|
+
if line[:1] in (" ", "\t"):
|
|
143
|
+
continue
|
|
144
|
+
key = line[:colon_index].strip()
|
|
145
|
+
value = line[colon_index + 1 :].strip()
|
|
146
|
+
result[key] = _strip_quotes(value)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_frontmatter_multiline(content: str) -> dict[str, str] | None:
|
|
151
|
+
fm_block = _frontmatter_block(content)
|
|
152
|
+
if fm_block is None:
|
|
153
|
+
return None
|
|
154
|
+
if fm_block == "":
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
result: dict[str, str] = {}
|
|
158
|
+
current_key: str | None = None
|
|
159
|
+
current_value = ""
|
|
160
|
+
|
|
161
|
+
for line in fm_block.split("\n"):
|
|
162
|
+
colon_index = line.find(":")
|
|
163
|
+
# New key: column 0 (no leading whitespace) and colon not at index 0
|
|
164
|
+
if colon_index > 0 and line[:1] not in (" ", "\t"):
|
|
165
|
+
if current_key is not None:
|
|
166
|
+
result[current_key] = _strip_quotes(current_value.strip())
|
|
167
|
+
current_key = line[:colon_index].strip()
|
|
168
|
+
current_value = line[colon_index + 1 :]
|
|
169
|
+
elif current_key is not None:
|
|
170
|
+
if line.lstrip().startswith("#"):
|
|
171
|
+
continue
|
|
172
|
+
current_value += "\n" + line
|
|
173
|
+
|
|
174
|
+
if current_key is not None:
|
|
175
|
+
result[current_key] = _strip_quotes(current_value.strip())
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- Safe File Reading ---
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def safe_read_file(file_path: str, findings: list[dict], rel_file: str | None) -> str | None:
|
|
183
|
+
try:
|
|
184
|
+
with open(file_path, encoding="utf-8", errors="replace", newline="") as f:
|
|
185
|
+
return f.read()
|
|
186
|
+
except OSError as error:
|
|
187
|
+
findings.append(
|
|
188
|
+
_finding(
|
|
189
|
+
"READ-ERR",
|
|
190
|
+
"File Read Error",
|
|
191
|
+
"MEDIUM",
|
|
192
|
+
rel_file or os.path.basename(file_path),
|
|
193
|
+
f"Cannot read file: {error}",
|
|
194
|
+
"Check file permissions and ensure the file exists.",
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- Code Block Stripping ---
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _blank(match: re.Match[str]) -> str:
|
|
204
|
+
return re.sub(r"[^\n]", "", match.group(0))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def strip_code_blocks(content: str) -> str:
|
|
208
|
+
return re.sub(r"```.*?```", _blank, content, flags=re.DOTALL)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --- Skill Discovery ---
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def discover_skill_dirs(root_dirs: list[str]) -> list[str]:
|
|
215
|
+
skill_dirs: list[str] = []
|
|
216
|
+
|
|
217
|
+
def walk(dir_path: str) -> None:
|
|
218
|
+
if not os.path.exists(dir_path):
|
|
219
|
+
return
|
|
220
|
+
with os.scandir(dir_path) as it:
|
|
221
|
+
entries = list(it)
|
|
222
|
+
for entry in entries:
|
|
223
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
224
|
+
continue
|
|
225
|
+
if entry.name in SKIP_DIRS:
|
|
226
|
+
continue
|
|
227
|
+
full_path = entry.path
|
|
228
|
+
if os.path.exists(os.path.join(full_path, "SKILL.md")):
|
|
229
|
+
skill_dirs.append(full_path)
|
|
230
|
+
walk(full_path)
|
|
231
|
+
|
|
232
|
+
for root_dir in root_dirs:
|
|
233
|
+
walk(root_dir)
|
|
234
|
+
skill_dirs.sort()
|
|
235
|
+
return skill_dirs
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --- File Collection ---
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def collect_skill_files(skill_dir: str, findings: list[dict]) -> list[str]:
|
|
242
|
+
files: list[str] = []
|
|
243
|
+
|
|
244
|
+
def walk(current_dir: str) -> None:
|
|
245
|
+
try:
|
|
246
|
+
with os.scandir(current_dir) as it:
|
|
247
|
+
entries = sorted(it, key=lambda e: e.name)
|
|
248
|
+
except OSError as error:
|
|
249
|
+
rel_file = _relpath(current_dir, skill_dir)
|
|
250
|
+
findings.append(
|
|
251
|
+
_finding(
|
|
252
|
+
"READ-ERR",
|
|
253
|
+
"File Read Error",
|
|
254
|
+
"MEDIUM",
|
|
255
|
+
rel_file or os.path.basename(current_dir),
|
|
256
|
+
f"Cannot read file: {error}",
|
|
257
|
+
"Check file permissions and ensure the file exists.",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
for entry in entries:
|
|
262
|
+
if entry.name in SKIP_DIRS:
|
|
263
|
+
continue
|
|
264
|
+
if entry.is_dir(follow_symlinks=False):
|
|
265
|
+
walk(entry.path)
|
|
266
|
+
elif entry.is_file(follow_symlinks=False):
|
|
267
|
+
files.append(entry.path)
|
|
268
|
+
|
|
269
|
+
walk(skill_dir)
|
|
270
|
+
return files
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --- Rule Checks ---
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def validate_skill(skill_dir: str) -> list[dict]:
|
|
277
|
+
findings: list[dict] = []
|
|
278
|
+
dir_name = os.path.basename(skill_dir)
|
|
279
|
+
skill_md_path = os.path.join(skill_dir, "SKILL.md")
|
|
280
|
+
|
|
281
|
+
all_files = collect_skill_files(skill_dir, findings)
|
|
282
|
+
|
|
283
|
+
if not os.path.exists(skill_md_path):
|
|
284
|
+
findings.append(
|
|
285
|
+
_finding(
|
|
286
|
+
"SKILL-01",
|
|
287
|
+
"SKILL.md Must Exist",
|
|
288
|
+
"CRITICAL",
|
|
289
|
+
"SKILL.md",
|
|
290
|
+
"SKILL.md not found in skill directory.",
|
|
291
|
+
"Create SKILL.md as the skill entrypoint.",
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
return findings
|
|
295
|
+
|
|
296
|
+
skill_content = safe_read_file(skill_md_path, findings, "SKILL.md")
|
|
297
|
+
if skill_content is None:
|
|
298
|
+
return findings
|
|
299
|
+
skill_fm = parse_frontmatter_multiline(skill_content)
|
|
300
|
+
|
|
301
|
+
if not skill_fm or "name" not in skill_fm:
|
|
302
|
+
findings.append(
|
|
303
|
+
_finding(
|
|
304
|
+
"SKILL-02",
|
|
305
|
+
"SKILL.md Must Have name in Frontmatter",
|
|
306
|
+
"CRITICAL",
|
|
307
|
+
"SKILL.md",
|
|
308
|
+
"Frontmatter is missing the `name` field.",
|
|
309
|
+
"Add `name: <skill-name>` to the frontmatter.",
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
elif skill_fm["name"] == "":
|
|
313
|
+
findings.append(
|
|
314
|
+
_finding(
|
|
315
|
+
"SKILL-02",
|
|
316
|
+
"SKILL.md Must Have name in Frontmatter",
|
|
317
|
+
"CRITICAL",
|
|
318
|
+
"SKILL.md",
|
|
319
|
+
"Frontmatter `name` field is empty.",
|
|
320
|
+
"Set `name` to the skill directory name (kebab-case).",
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if not skill_fm or "description" not in skill_fm:
|
|
325
|
+
findings.append(
|
|
326
|
+
_finding(
|
|
327
|
+
"SKILL-03",
|
|
328
|
+
"SKILL.md Must Have description in Frontmatter",
|
|
329
|
+
"CRITICAL",
|
|
330
|
+
"SKILL.md",
|
|
331
|
+
"Frontmatter is missing the `description` field.",
|
|
332
|
+
"Add `description: <what it does and when to use it>` to the frontmatter.",
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
elif skill_fm["description"] == "":
|
|
336
|
+
findings.append(
|
|
337
|
+
_finding(
|
|
338
|
+
"SKILL-03",
|
|
339
|
+
"SKILL.md Must Have description in Frontmatter",
|
|
340
|
+
"CRITICAL",
|
|
341
|
+
"SKILL.md",
|
|
342
|
+
"Frontmatter `description` field is empty.",
|
|
343
|
+
"Add a description stating what the skill does and when to use it.",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
name = skill_fm.get("name") if skill_fm else None
|
|
348
|
+
description = skill_fm.get("description") if skill_fm else None
|
|
349
|
+
|
|
350
|
+
is_deprecated = isinstance(description, str) and bool(DEPRECATED_RE.search(description))
|
|
351
|
+
|
|
352
|
+
if name and not NAME_REGEX.search(name):
|
|
353
|
+
findings.append(
|
|
354
|
+
_finding(
|
|
355
|
+
"SKILL-04",
|
|
356
|
+
"name Format",
|
|
357
|
+
"HIGH",
|
|
358
|
+
"SKILL.md",
|
|
359
|
+
f'name "{name}" does not match pattern: {NAME_REGEX_DISPLAY}',
|
|
360
|
+
"Rename to comply with lowercase letters, numbers, and hyphens only (max 64 chars).",
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if name and name != dir_name:
|
|
365
|
+
findings.append(
|
|
366
|
+
_finding(
|
|
367
|
+
"SKILL-05",
|
|
368
|
+
"name Must Match Directory Name",
|
|
369
|
+
"HIGH",
|
|
370
|
+
"SKILL.md",
|
|
371
|
+
f'name "{name}" does not match directory name "{dir_name}".',
|
|
372
|
+
f'Change name to "{dir_name}" or rename the directory.',
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if description:
|
|
377
|
+
if len(description) > 1024:
|
|
378
|
+
findings.append(
|
|
379
|
+
_finding(
|
|
380
|
+
"SKILL-06",
|
|
381
|
+
"description Quality",
|
|
382
|
+
"MEDIUM",
|
|
383
|
+
"SKILL.md",
|
|
384
|
+
f"description is {len(description)} characters (max 1024).",
|
|
385
|
+
"Shorten the description to 1024 characters or less.",
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
if not is_deprecated and not USE_WHEN_RE.search(description) and not USE_IF_RE.search(description):
|
|
390
|
+
findings.append(
|
|
391
|
+
_finding(
|
|
392
|
+
"SKILL-06",
|
|
393
|
+
"description Quality",
|
|
394
|
+
"MEDIUM",
|
|
395
|
+
"SKILL.md",
|
|
396
|
+
'description does not contain "Use when" or "Use if" trigger phrase.',
|
|
397
|
+
'Append a "Use when..." clause to explain when to invoke this skill.',
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
trimmed = skill_content.lstrip()
|
|
402
|
+
body_start = -1
|
|
403
|
+
if trimmed.startswith("---"):
|
|
404
|
+
end_idx = trimmed.find("\n---\n", 3)
|
|
405
|
+
if end_idx != -1:
|
|
406
|
+
body_start = end_idx + 4
|
|
407
|
+
elif trimmed.endswith("\n---"):
|
|
408
|
+
body_start = len(trimmed)
|
|
409
|
+
else:
|
|
410
|
+
body_start = 0
|
|
411
|
+
body = trimmed[body_start:].strip() if body_start >= 0 else ""
|
|
412
|
+
if body == "":
|
|
413
|
+
findings.append(
|
|
414
|
+
_finding(
|
|
415
|
+
"SKILL-07",
|
|
416
|
+
"SKILL.md Must Have Body Content",
|
|
417
|
+
"HIGH",
|
|
418
|
+
"SKILL.md",
|
|
419
|
+
"SKILL.md has no content after frontmatter. L2 instructions are required.",
|
|
420
|
+
"Add markdown body with skill instructions after the closing ---.",
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
for file_path in all_files:
|
|
425
|
+
ext = os.path.splitext(file_path)[1]
|
|
426
|
+
if ext not in SCAN_EXTENSIONS:
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
rel_file = _relpath(file_path, skill_dir)
|
|
430
|
+
content = safe_read_file(file_path, findings, rel_file)
|
|
431
|
+
if content is None:
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
fm = parse_frontmatter(content)
|
|
435
|
+
if fm and "installed_path" in fm:
|
|
436
|
+
findings.append(
|
|
437
|
+
_finding(
|
|
438
|
+
"PATH-02",
|
|
439
|
+
"No installed_path Variable",
|
|
440
|
+
"HIGH",
|
|
441
|
+
rel_file,
|
|
442
|
+
"Frontmatter contains `installed_path:` key.",
|
|
443
|
+
"Remove `installed_path` from frontmatter. Use relative paths instead.",
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
stripped = strip_code_blocks(content)
|
|
448
|
+
for i, line in enumerate(stripped.split("\n")):
|
|
449
|
+
if INSTALLED_PATH_RE.search(line):
|
|
450
|
+
findings.append(
|
|
451
|
+
_finding(
|
|
452
|
+
"PATH-02",
|
|
453
|
+
"No installed_path Variable",
|
|
454
|
+
"HIGH",
|
|
455
|
+
rel_file,
|
|
456
|
+
"`installed_path` reference found in content.",
|
|
457
|
+
"Remove all installed_path usage. Use relative paths (`./path` or `../path`) instead.",
|
|
458
|
+
line=i + 1,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
for file_path in all_files:
|
|
463
|
+
ext = os.path.splitext(file_path)[1]
|
|
464
|
+
if ext not in SCAN_EXTENSIONS:
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
rel_file = _relpath(file_path, skill_dir)
|
|
468
|
+
content = safe_read_file(file_path, findings, rel_file)
|
|
469
|
+
if content is None:
|
|
470
|
+
continue
|
|
471
|
+
stripped = strip_code_blocks(content)
|
|
472
|
+
|
|
473
|
+
for i, line in enumerate(stripped.split("\n")):
|
|
474
|
+
for pattern in TIME_ESTIMATE_PATTERNS:
|
|
475
|
+
if pattern.search(line):
|
|
476
|
+
findings.append(
|
|
477
|
+
_finding(
|
|
478
|
+
"SEQ-02",
|
|
479
|
+
"No Time Estimates",
|
|
480
|
+
"LOW",
|
|
481
|
+
rel_file,
|
|
482
|
+
f'Time estimate pattern found: "{line.strip()}"',
|
|
483
|
+
"Remove time estimates — AI execution speed varies too much.",
|
|
484
|
+
line=i + 1,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
break
|
|
488
|
+
|
|
489
|
+
for file_path in all_files:
|
|
490
|
+
if os.path.splitext(file_path)[1] != ".md":
|
|
491
|
+
continue
|
|
492
|
+
base = os.path.basename(file_path)
|
|
493
|
+
if not TEMPLATE_FILENAME_REGEX.search(base):
|
|
494
|
+
continue
|
|
495
|
+
|
|
496
|
+
rel_file = _relpath(file_path, skill_dir)
|
|
497
|
+
content = safe_read_file(file_path, findings, rel_file)
|
|
498
|
+
if content is None:
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
for i, line in enumerate(content.split("\n")):
|
|
502
|
+
match = COMPILE_TIME_SUB_REGEX.search(line)
|
|
503
|
+
if match:
|
|
504
|
+
findings.append(
|
|
505
|
+
_finding(
|
|
506
|
+
"TPL-01",
|
|
507
|
+
"Template files must not contain compile-time substitutions",
|
|
508
|
+
"HIGH",
|
|
509
|
+
rel_file,
|
|
510
|
+
f"Template file contains compile-time substitution `{match.group(0)}` — this would be baked at render time and leak a machine-local value into every spec produced from the template.",
|
|
511
|
+
"Remove the `{{.var}}` reference. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated spec.",
|
|
512
|
+
line=i + 1,
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
return findings
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# --- Output Formatting ---
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def format_human_readable(
|
|
523
|
+
results: list[dict],
|
|
524
|
+
project_root: str,
|
|
525
|
+
src_dir: str,
|
|
526
|
+
strict: bool,
|
|
527
|
+
json_output: bool,
|
|
528
|
+
github_actions: bool,
|
|
529
|
+
) -> tuple[str, bool]:
|
|
530
|
+
output: list[str] = []
|
|
531
|
+
total_findings = 0
|
|
532
|
+
severity_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
|
533
|
+
|
|
534
|
+
mode = "STRICT (exit 1 on HIGH+)" if strict else "WARNING (exit 0)"
|
|
535
|
+
if json_output:
|
|
536
|
+
mode += " + JSON"
|
|
537
|
+
output.extend(
|
|
538
|
+
[
|
|
539
|
+
f"\nValidating skills in: {src_dir}",
|
|
540
|
+
f"Mode: {mode}\n",
|
|
541
|
+
]
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
total_skills = 0
|
|
545
|
+
skills_with_findings = 0
|
|
546
|
+
|
|
547
|
+
for result in results:
|
|
548
|
+
skill_dir = result["skillDir"]
|
|
549
|
+
findings = result["findings"]
|
|
550
|
+
total_skills += 1
|
|
551
|
+
rel_dir = _relpath(skill_dir, project_root)
|
|
552
|
+
|
|
553
|
+
if findings:
|
|
554
|
+
skills_with_findings += 1
|
|
555
|
+
output.append(f"\n{rel_dir}")
|
|
556
|
+
|
|
557
|
+
for f in findings:
|
|
558
|
+
total_findings += 1
|
|
559
|
+
severity_counts[f["severity"]] += 1
|
|
560
|
+
location = f" (line {f['line']})" if f.get("line") else ""
|
|
561
|
+
output.extend(
|
|
562
|
+
[
|
|
563
|
+
f" [{f['severity']}] {f['rule']} — {f['title']}",
|
|
564
|
+
f" File: {f['file']}{location}",
|
|
565
|
+
f" {f['detail']}",
|
|
566
|
+
]
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if github_actions:
|
|
570
|
+
abs_file = os.path.join(skill_dir, f["file"])
|
|
571
|
+
gh_file = _relpath(abs_file, project_root)
|
|
572
|
+
line = f.get("line") or 1
|
|
573
|
+
level = "notice" if f["severity"] == "LOW" else "warning"
|
|
574
|
+
message = escape_annotation(f"{f['rule']}: {f['detail']}")
|
|
575
|
+
print(f"::{level} file={gh_file},line={line}::{message}")
|
|
576
|
+
|
|
577
|
+
output.extend(
|
|
578
|
+
[
|
|
579
|
+
f"\n{'─' * 60}",
|
|
580
|
+
"\nSummary:",
|
|
581
|
+
f" Skills scanned: {total_skills}",
|
|
582
|
+
f" Skills with findings: {skills_with_findings}",
|
|
583
|
+
f" Total findings: {total_findings}",
|
|
584
|
+
]
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
if total_findings > 0:
|
|
588
|
+
output.extend(["", " | Severity | Count |", " |----------|-------|"])
|
|
589
|
+
for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
|
|
590
|
+
if severity_counts[sev] > 0:
|
|
591
|
+
output.append(f" | {sev:<8} | {severity_counts[sev]:>5} |")
|
|
592
|
+
|
|
593
|
+
has_high_plus = severity_counts["CRITICAL"] > 0 or severity_counts["HIGH"] > 0
|
|
594
|
+
|
|
595
|
+
if total_findings == 0:
|
|
596
|
+
output.append("\n All skills passed validation!")
|
|
597
|
+
elif strict and has_high_plus:
|
|
598
|
+
output.append("\n [STRICT MODE] HIGH+ findings found — exiting with failure.")
|
|
599
|
+
elif strict:
|
|
600
|
+
output.append("\n [STRICT MODE] Only MEDIUM/LOW findings — pass.")
|
|
601
|
+
else:
|
|
602
|
+
output.append("\n Run with --strict to treat HIGH+ findings as errors.")
|
|
603
|
+
|
|
604
|
+
output.append("")
|
|
605
|
+
|
|
606
|
+
step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
607
|
+
if step_summary:
|
|
608
|
+
summary = "## Skill Validation\n\n"
|
|
609
|
+
if total_findings > 0:
|
|
610
|
+
summary += "| Skill | Rule | Severity | File | Detail |\n"
|
|
611
|
+
summary += "|-------|------|----------|------|--------|\n"
|
|
612
|
+
for result in results:
|
|
613
|
+
rel_dir = _relpath(result["skillDir"], project_root)
|
|
614
|
+
for f in result["findings"]:
|
|
615
|
+
summary += (
|
|
616
|
+
f"| {escape_table_cell(rel_dir)} | {f['rule']} | {f['severity']} | "
|
|
617
|
+
f"{escape_table_cell(f['file'])} | {escape_table_cell(f['detail'])} |\n"
|
|
618
|
+
)
|
|
619
|
+
summary += "\n"
|
|
620
|
+
summary += f"**{total_skills} skills scanned, {total_findings} findings**\n"
|
|
621
|
+
with open(step_summary, "a", encoding="utf-8") as fh:
|
|
622
|
+
fh.write(summary)
|
|
623
|
+
|
|
624
|
+
return "\n".join(output), has_high_plus
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def format_json(results: list[dict], project_root: str) -> tuple[str, bool]:
|
|
628
|
+
all_findings: list[dict] = []
|
|
629
|
+
for result in results:
|
|
630
|
+
rel_dir = _relpath(result["skillDir"], project_root)
|
|
631
|
+
for f in result["findings"]:
|
|
632
|
+
all_findings.append(
|
|
633
|
+
{
|
|
634
|
+
"skill": rel_dir,
|
|
635
|
+
"rule": f["rule"],
|
|
636
|
+
"title": f["title"],
|
|
637
|
+
"severity": f["severity"],
|
|
638
|
+
"file": f["file"],
|
|
639
|
+
"line": f.get("line") or None,
|
|
640
|
+
"detail": f["detail"],
|
|
641
|
+
"fix": f["fix"],
|
|
642
|
+
}
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
all_findings.sort(key=lambda item: SEVERITY_ORDER[item["severity"]])
|
|
646
|
+
has_high_plus = any(f["severity"] in ("CRITICAL", "HIGH") for f in all_findings)
|
|
647
|
+
return json.dumps(all_findings, indent=2, ensure_ascii=False), has_high_plus
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# --- Main ---
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def run(
|
|
654
|
+
project_root: str,
|
|
655
|
+
skill_dir: str | None = None,
|
|
656
|
+
strict: bool = False,
|
|
657
|
+
json_output: bool = False,
|
|
658
|
+
) -> int:
|
|
659
|
+
src_dir = os.path.join(project_root, "src")
|
|
660
|
+
github_actions = bool(os.environ.get("GITHUB_ACTIONS"))
|
|
661
|
+
|
|
662
|
+
if skill_dir is not None:
|
|
663
|
+
target = os.path.abspath(skill_dir)
|
|
664
|
+
if not os.path.isdir(target):
|
|
665
|
+
print(f'Error: "{skill_dir}" is not a valid directory.', file=sys.stderr)
|
|
666
|
+
return 2
|
|
667
|
+
skill_dirs = [target]
|
|
668
|
+
else:
|
|
669
|
+
skill_dirs = discover_skill_dirs([src_dir])
|
|
670
|
+
|
|
671
|
+
if not skill_dirs:
|
|
672
|
+
print("No skill directories found.", file=sys.stderr)
|
|
673
|
+
return 2
|
|
674
|
+
|
|
675
|
+
results = [{"skillDir": d, "findings": validate_skill(d)} for d in skill_dirs]
|
|
676
|
+
|
|
677
|
+
if json_output:
|
|
678
|
+
output, has_high_plus = format_json(results, project_root)
|
|
679
|
+
else:
|
|
680
|
+
output, has_high_plus = format_human_readable(
|
|
681
|
+
results, project_root, src_dir, strict, json_output, github_actions
|
|
682
|
+
)
|
|
683
|
+
print(output)
|
|
684
|
+
|
|
685
|
+
return 1 if strict and has_high_plus else 0
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def main(argv: list[str] | None = None) -> int:
|
|
689
|
+
parser = argparse.ArgumentParser(description="Validate skill directories against deterministic rules.")
|
|
690
|
+
parser.add_argument("--strict", action="store_true", help="exit 1 on HIGH+ findings")
|
|
691
|
+
parser.add_argument("--json", action="store_true", dest="json_output", help="JSON output")
|
|
692
|
+
parser.add_argument("skill_dir", nargs="?", default=None, help="single skill directory")
|
|
693
|
+
args = parser.parse_args(argv)
|
|
694
|
+
return run(PROJECT_ROOT, skill_dir=args.skill_dir, strict=args.strict, json_output=args.json_output)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
if __name__ == "__main__":
|
|
698
|
+
sys.exit(main())
|