bmad-module-skill-forge 1.1.0 → 1.2.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.
@@ -0,0 +1,359 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Detect Tools — Parallel tool detection + tier calculation for skf-setup.
6
+
7
+ Replaces the prose-driven tool-detection sequence in `src/skf-setup/steps-c/
8
+ step-01-detect-and-tier.md` §3-§8b with one Python invocation. Probes ast-grep,
9
+ gh, qmd, and ccc concurrently, applies the 4-rule tier decision table (see
10
+ `src/skf-setup/references/tier-rules.md`), evaluates --tier-override (with
11
+ sanity check) and --require-tier (with tool-prerequisite check independent of
12
+ the tier name), and emits one JSON document on stdout.
13
+
14
+ Schema documented in DETECT_OUTPUT_SCHEMA at the bottom of this docstring.
15
+ The output is consumed by step-01 prose, step-02 (forge-tier.yaml writer),
16
+ and step-04 (status report + envelope).
17
+
18
+ Tier rules (first match wins):
19
+ Deep = ast-grep + gh-cli + qmd (all healthy)
20
+ Forge+ = ast-grep + ccc (regardless of gh/qmd)
21
+ Forge = ast-grep
22
+ Quick = otherwise
23
+
24
+ CCC verification is two-step (matches step-01 §7):
25
+ Step A: `ccc --help` exits 0 AND output contains "CocoIndex Code" marker.
26
+ Rejects code2prompt-aliased-as-ccc and similar PATH shadowing.
27
+ Step B: `ccc doctor` succeeds (daemon healthy).
28
+
29
+ QMD verification is two-step (matches step-01 §5 post-PR-#248):
30
+ Step A: `qmd --version` exits 0 (binary identity, falls back to --help).
31
+ Step B: `qmd status` succeeds (daemon healthy).
32
+ qmd_status: "absent" | "daemon_stopped" | "healthy" — affects climb hint.
33
+
34
+ Exit codes:
35
+ 0 detection completed (status=ok in payload; require_tier may still be
36
+ unsatisfied — that is a payload field, not an exit signal here)
37
+ 1 user error (bad args)
38
+ 2 internal error (timeout, subprocess crash that escaped the per-probe
39
+ guard)
40
+
41
+ CLI (canonical invocation is `uv run` so PEP 723 inline metadata is
42
+ honored — see docs/getting-started.md for why uv is the documented
43
+ runtime prerequisite):
44
+
45
+ uv run skf-detect-tools.py
46
+ uv run skf-detect-tools.py --tier-override Deep
47
+ uv run skf-detect-tools.py --require-tier Forge+
48
+ uv run skf-detect-tools.py --snyk-env-var SNYK_TOKEN
49
+
50
+ Bare `python3` works when dependencies = [] (this script's case) but
51
+ becomes brittle the moment a non-stdlib dep is added — prefer `uv run`
52
+ for invocation consistency with sibling scripts that DO require pyyaml.
53
+
54
+ DETECT_OUTPUT_SCHEMA (v1):
55
+ {
56
+ "status": "ok",
57
+ "version": "v1",
58
+ "tools": {
59
+ "ast_grep": {"available": bool, "version": str|null},
60
+ "gh_cli": {"available": bool, "version": str|null},
61
+ "qmd": {"available": bool, "status": "absent"|"daemon_stopped"|"healthy",
62
+ "version": str|null},
63
+ "ccc": {"available": bool, "daemon": "healthy"|"stopped"|"error"|null,
64
+ "version": str|null},
65
+ "security_scan": {"available": bool}
66
+ },
67
+ "tier": {
68
+ "calculated": "Quick"|"Forge"|"Forge+"|"Deep",
69
+ "detected": "Quick"|"Forge"|"Forge+"|"Deep",
70
+ "override_applied": bool,
71
+ "override_value": str|null,
72
+ "override_invalid": bool,
73
+ "override_invalid_value": str|null,
74
+ "override_invalid_suggestion": str|null,
75
+ "override_unsafe": bool,
76
+ "override_unsafe_missing": [str]
77
+ },
78
+ "require_tier": {
79
+ "requested": "Quick"|"Forge"|"Forge+"|"Deep"|null,
80
+ "satisfied": bool|null,
81
+ "missing_tools": [str]
82
+ }
83
+ }
84
+ """
85
+
86
+ from __future__ import annotations
87
+
88
+ import argparse
89
+ import json
90
+ import os
91
+ import subprocess
92
+ import sys
93
+ from concurrent.futures import ThreadPoolExecutor
94
+
95
+
96
+ VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
97
+ PROBE_TIMEOUT_SEC = 8 # per-tool subprocess.run timeout
98
+ CCC_IDENTITY_MARKER = "cocoindex code" # case-insensitive substring
99
+
100
+
101
+ def _die(code: int, message: str) -> None:
102
+ print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
103
+ sys.exit(code)
104
+
105
+
106
+ def _ok(payload: dict) -> None:
107
+ payload.setdefault("status", "ok")
108
+ payload.setdefault("version", "v1")
109
+ print(json.dumps(payload))
110
+
111
+
112
+ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, str]:
113
+ """Run a subprocess. Return (returncode, stdout, stderr). Never raises.
114
+
115
+ Treats every failure mode (FileNotFoundError, TimeoutExpired, OSError,
116
+ CalledProcessError) as a failed probe — returns rc=127 and an empty
117
+ stdout/stderr. Tool detection should never crash the workflow.
118
+ """
119
+ try:
120
+ result = subprocess.run(
121
+ cmd,
122
+ capture_output=True,
123
+ text=True,
124
+ timeout=timeout,
125
+ check=False,
126
+ )
127
+ return result.returncode, result.stdout or "", result.stderr or ""
128
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
129
+ return 127, "", ""
130
+
131
+
132
+ def _first_line(text: str) -> str | None:
133
+ """Return the first non-empty stripped line of `text`, or None."""
134
+ for line in text.splitlines():
135
+ stripped = line.strip()
136
+ if stripped:
137
+ return stripped
138
+ return None
139
+
140
+
141
+ def probe_ast_grep() -> dict:
142
+ rc, stdout, _ = _run(["ast-grep", "--version"])
143
+ if rc != 0:
144
+ return {"available": False, "version": None}
145
+ return {"available": True, "version": _first_line(stdout)}
146
+
147
+
148
+ def probe_gh_cli() -> dict:
149
+ rc, stdout, _ = _run(["gh", "--version"])
150
+ if rc != 0:
151
+ return {"available": False, "version": None}
152
+ return {"available": True, "version": _first_line(stdout)}
153
+
154
+
155
+ def probe_qmd() -> dict:
156
+ """Two-step probe: --version (binary identity) then status (daemon health)."""
157
+ rc, stdout, _ = _run(["qmd", "--version"])
158
+ if rc != 0:
159
+ # --version may not be supported on every qmd build; fall back to --help.
160
+ rc, stdout, _ = _run(["qmd", "--help"])
161
+ if rc != 0:
162
+ return {"available": False, "status": "absent", "version": None}
163
+ version = _first_line(stdout)
164
+
165
+ rc_status, _, _ = _run(["qmd", "status"])
166
+ if rc_status != 0:
167
+ return {"available": False, "status": "daemon_stopped", "version": version}
168
+ return {"available": True, "status": "healthy", "version": version}
169
+
170
+
171
+ def probe_ccc() -> dict:
172
+ """Two-step probe: --help with identity marker, then doctor for daemon health."""
173
+ rc, stdout, _ = _run(["ccc", "--help"])
174
+ if rc != 0:
175
+ return {"available": False, "daemon": None, "version": None}
176
+ if CCC_IDENTITY_MARKER not in stdout.lower():
177
+ # `ccc` resolved to a foreign binary (e.g. code2prompt alias). Refuse.
178
+ return {"available": False, "daemon": None, "version": None}
179
+
180
+ rc_doctor, doctor_stdout, _ = _run(["ccc", "doctor"])
181
+ version = _first_line(doctor_stdout) or _first_line(stdout)
182
+ if rc_doctor == 0:
183
+ return {"available": True, "daemon": "healthy", "version": version}
184
+ # Distinguishing "stopped" from "error" requires parsing doctor output;
185
+ # without a documented contract, default to "error" and let the caller
186
+ # treat both as operational unavailability with daemon-level remediation.
187
+ return {"available": True, "daemon": "error", "version": version}
188
+
189
+
190
+ def probe_security_scan(env_var: str) -> dict:
191
+ """Informational only — does NOT affect tier."""
192
+ return {"available": bool(os.environ.get(env_var, "").strip())}
193
+
194
+
195
+ def calculate_tier(tools: dict) -> str:
196
+ ag = tools["ast_grep"]["available"]
197
+ gh = tools["gh_cli"]["available"]
198
+ qm = tools["qmd"]["available"]
199
+ cc = tools["ccc"]["available"]
200
+
201
+ if ag and gh and qm:
202
+ return "Deep"
203
+ if ag and cc:
204
+ return "Forge+"
205
+ if ag:
206
+ return "Forge"
207
+ return "Quick"
208
+
209
+
210
+ def suggest_valid_tier(bad_value: str) -> str | None:
211
+ """For an invalid --tier-override value, return the closest valid tier name.
212
+
213
+ Two-stage match:
214
+ 1. Case-insensitive exact match — handles `deep` / `DEEP` / `forge+` /
215
+ `FORGE+` (the most common typo class: right tier, wrong case).
216
+ 2. difflib fuzzy match against `VALID_TIERS` with a 0.6 cutoff — handles
217
+ `frorge`, `quik`, `forge plus`, etc.
218
+
219
+ Returns None if no candidate clears the cutoff. Used only for diagnostic
220
+ messages — the override itself is never silently auto-corrected.
221
+ """
222
+ import difflib
223
+
224
+ if not bad_value or not isinstance(bad_value, str):
225
+ return None
226
+ cleaned = bad_value.strip()
227
+ if not cleaned:
228
+ return None
229
+ cleaned_lower = cleaned.lower()
230
+ for valid in VALID_TIERS:
231
+ if valid.lower() == cleaned_lower:
232
+ return valid
233
+ matches = difflib.get_close_matches(cleaned, VALID_TIERS, n=1, cutoff=0.6)
234
+ return matches[0] if matches else None
235
+
236
+
237
+ def tier_prerequisites_met(tier: str, tools: dict) -> tuple[bool, list[str]]:
238
+ """Return (satisfied, missing_tools) for tier-prerequisite checks.
239
+
240
+ Used by both --tier-override sanity check and --require-tier evaluation.
241
+ Deep does NOT subsume Forge+ (Deep does not require ccc), so a Deep
242
+ calculation with no ccc still fails a Forge+ requirement.
243
+ """
244
+ needed: dict[str, str] = {
245
+ "Quick": {},
246
+ "Forge": {"ast_grep": "ast-grep"},
247
+ "Forge+": {"ast_grep": "ast-grep", "ccc": "ccc"},
248
+ "Deep": {"ast_grep": "ast-grep", "gh_cli": "gh", "qmd": "qmd"},
249
+ }[tier]
250
+ missing = [display for key, display in needed.items() if not tools[key]["available"]]
251
+ return (len(missing) == 0, missing)
252
+
253
+
254
+ def detect(args: argparse.Namespace) -> dict:
255
+ tools: dict = {}
256
+ with ThreadPoolExecutor(max_workers=4) as ex:
257
+ futures = {
258
+ "ast_grep": ex.submit(probe_ast_grep),
259
+ "gh_cli": ex.submit(probe_gh_cli),
260
+ "qmd": ex.submit(probe_qmd),
261
+ "ccc": ex.submit(probe_ccc),
262
+ }
263
+ for key, fut in futures.items():
264
+ tools[key] = fut.result()
265
+ tools["security_scan"] = probe_security_scan(args.snyk_env_var)
266
+
267
+ detected = calculate_tier(tools)
268
+
269
+ # Tier override handling
270
+ override_applied = False
271
+ override_value: str | None = None
272
+ override_invalid = False
273
+ override_invalid_value: str | None = None
274
+ override_invalid_suggestion: str | None = None
275
+ override_unsafe = False
276
+ override_unsafe_missing: list[str] = []
277
+
278
+ if args.tier_override is not None:
279
+ if args.tier_override in VALID_TIERS:
280
+ override_applied = True
281
+ override_value = args.tier_override
282
+ calculated = args.tier_override
283
+ satisfied, missing = tier_prerequisites_met(calculated, tools)
284
+ if not satisfied:
285
+ override_unsafe = True
286
+ override_unsafe_missing = missing
287
+ else:
288
+ override_invalid = True
289
+ override_invalid_value = args.tier_override
290
+ override_invalid_suggestion = suggest_valid_tier(args.tier_override)
291
+ calculated = detected
292
+ else:
293
+ calculated = detected
294
+
295
+ # Require-tier evaluation
296
+ require_satisfied: bool | None
297
+ require_missing: list[str] = []
298
+ if args.require_tier is not None:
299
+ if args.require_tier not in VALID_TIERS:
300
+ _die(1, f"--require-tier must be one of {VALID_TIERS}, got {args.require_tier!r}")
301
+ require_satisfied, require_missing = tier_prerequisites_met(args.require_tier, tools)
302
+ else:
303
+ require_satisfied = None
304
+
305
+ return {
306
+ "tools": tools,
307
+ "tier": {
308
+ "calculated": calculated,
309
+ "detected": detected,
310
+ "override_applied": override_applied,
311
+ "override_value": override_value,
312
+ "override_invalid": override_invalid,
313
+ "override_invalid_value": override_invalid_value,
314
+ "override_invalid_suggestion": override_invalid_suggestion,
315
+ "override_unsafe": override_unsafe,
316
+ "override_unsafe_missing": override_unsafe_missing,
317
+ },
318
+ "require_tier": {
319
+ "requested": args.require_tier,
320
+ "satisfied": require_satisfied,
321
+ "missing_tools": require_missing,
322
+ },
323
+ }
324
+
325
+
326
+ def main() -> None:
327
+ parser = argparse.ArgumentParser(
328
+ description="Detect SKF tools and calculate capability tier.",
329
+ formatter_class=argparse.RawDescriptionHelpFormatter,
330
+ )
331
+ parser.add_argument(
332
+ "--tier-override",
333
+ default=None,
334
+ help="Force a specific tier (must be one of Quick, Forge, Forge+, Deep — case-sensitive)."
335
+ " Invalid values are flagged in the output rather than rejected, so step-04 can"
336
+ " surface the warning to the user.",
337
+ )
338
+ parser.add_argument(
339
+ "--require-tier",
340
+ default=None,
341
+ help="Require the calculated tier to satisfy this requirement (uses tool-prerequisite"
342
+ " check, not tier-name comparison — Deep does not subsume Forge+ because Deep"
343
+ " does not require ccc). Output reports satisfied/missing-tools; caller decides"
344
+ " whether to halt.",
345
+ )
346
+ parser.add_argument(
347
+ "--snyk-env-var",
348
+ default="SNYK_TOKEN",
349
+ help="Environment variable name to check for security-scan availability"
350
+ " (informational only — does NOT affect tier). Default: SNYK_TOKEN.",
351
+ )
352
+ args = parser.parse_args()
353
+
354
+ payload = detect(args)
355
+ _ok(payload)
356
+
357
+
358
+ if __name__ == "__main__":
359
+ main()