feat(installer): check for uv and standardize messaging on uv run (#2495)

Replace the standalone python3 environment check with a uv check, since
uv is becoming the de facto standard for running BMad's Python scripts
(`uv run <script>`) and uv provisions the interpreter itself.

Installer:
- Remove tools/installer/core/python-check.js and its wiring in ui.js
- Add tools/installer/core/uv-check.js: warn-don't-block, no ack prompt
  (the migration is in progress, so a missing uv never blocks install).
  Missing uv warns and points the user at setup, preferring "ask your
  agent to set up uv"
- Add a uv heads-up to the install intro (install-messages.yaml) and a
  uv tip line to the final "BMAD is ready" summary box
- Swap test Suite 46 from python-check to uv-check coverage

Docs and script comments (no functional skill invocations changed):
- resolve_config.py / resolve_customization.py docstrings: drop the
  "No uv ... plain python3 is sufficient" claims; frame uv run as the
  standard, python3 as the transition fallback; examples use uv run
- customize-bmad.md (en/fr/vi-vn): same reframing; example commands use
  uv run
- Update uv run hints in brain.py / list_customizable test comments
This commit is contained in:
Brian 2026-06-22 00:03:03 -05:00 committed by GitHub
parent 005ef1104a
commit 02739932bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 188 additions and 349 deletions

View File

@ -22,7 +22,7 @@ Le skill `bmad-customize` est un assistant de rédaction guidée pour les **opti
:::note[Prérequis] :::note[Prérequis]
- BMad installé dans votre projet (voir [Comment installer BMad](./install-bmad.md)) - BMad installé dans votre projet (voir [Comment installer BMad](./install-bmad.md))
- Python 3.11+ sur votre PATH (pour le script de résolution — utilise `tomllib` de la bibliothèque standard, pas de `pip install`, pas de `uv`, pas de virtualenv) - Un moyen dexécuter le script de résolution — BMad adopte `uv` comme standard (`uv run`, qui provisionne Python pour vous) ; un simple `python3` 3.11+ sur votre PATH fonctionne toujours pendant la transition. Le script nutilise que `tomllib` de la bibliothèque standard, il ny a donc rien à `pip install`.
- Un éditeur de texte pour les fichiers TOML - Un éditeur de texte pour les fichiers TOML
::: :::
@ -201,15 +201,15 @@ persistent_facts = [
## Comment fonctionne la résolution ## Comment fonctionne la résolution
À lactivation, le SKILL.md de lagent exécute un script Python partagé qui effectue la fusion à trois couches et renvoie le bloc résolu en JSON. Le script utilise le module `tomllib` de la bibliothèque standard Python (aucune dépendance externe), donc `python3` suffit : À lactivation, le SKILL.md de lagent exécute un script Python partagé qui effectue la fusion à trois couches et renvoie le bloc résolu en JSON. Le script utilise uniquement le module `tomllib` de la bibliothèque standard Python (aucune dépendance externe). BMad adopte `uv run` comme standard pour exécuter ces scripts (uv provisionne un Python adapté pour vous) ; un simple `python3` fonctionne toujours pendant la transition :
```bash ```bash
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill {skill-root} \ --skill {skill-root} \
--key agent --key agent
``` ```
**Prérequis** : Python 3.11+ (les versions antérieures nincluent pas `tomllib`). Pas de `pip install`, pas de `uv`, pas de virtualenv. Vérifiez avec `python3 --version`. Certaines plateformes (macOS sans Homebrew, Ubuntu 22.04) ont `python3` par défaut en 3.10 ou antérieur, vous devrez peut-être installer 3.11+ séparément. **Prérequis** : Python 3.11+ (les versions antérieures nincluent pas `tomllib`). Rien à `pip install`. Lexécution via `uv run` est le standard à venir — uv résout un interpréteur adapté pour vous. Si vous lexécutez directement avec `python3` pendant la transition, vérifiez votre version avec `python3 --version` ; certaines plateformes (macOS sans Homebrew, Ubuntu 22.04) ont `python3` par défaut en 3.10 ou antérieur, vous devrez peut-être installer 3.11+ séparément.
`--skill` pointe vers le répertoire installé du skill (où se trouve `customize.toml`). Le nom du skill est déduit du basename du répertoire, et le script cherche automatiquement `_bmad/custom/{skill-name}.toml` et `{skill-name}.user.toml`. `--skill` pointe vers le répertoire installé du skill (où se trouve `customize.toml`). Le nom du skill est déduit du basename du répertoire, et le script cherche automatiquement `_bmad/custom/{skill-name}.toml` et `{skill-name}.user.toml`.
@ -217,17 +217,17 @@ Exemples dutilisation :
```bash ```bash
# Résoudre le bloc agent complet # Résoudre le bloc agent complet
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /chemin/absolu/vers/bmad-agent-pm \ --skill /chemin/absolu/vers/bmad-agent-pm \
--key agent --key agent
# Résoudre un seul champ # Résoudre un seul champ
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /chemin/absolu/vers/bmad-agent-pm \ --skill /chemin/absolu/vers/bmad-agent-pm \
--key agent.icon --key agent.icon
# Dump complet # Dump complet
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /chemin/absolu/vers/bmad-agent-pm --skill /chemin/absolu/vers/bmad-agent-pm
``` ```

View File

@ -22,7 +22,7 @@ The `bmad-customize` skill is a guided authoring helper for the **per-skill agen
:::note[Prerequisites] :::note[Prerequisites]
- BMad installed in your project (see [How to Install BMad](./install-bmad.md)) - BMad installed in your project (see [How to Install BMad](./install-bmad.md))
- Python 3.11+ on your PATH (for the resolver script -- uses stdlib `tomllib`, no `pip install`, no `uv`, no virtualenv) - A way to run the resolver script — BMad is standardizing on `uv` (`uv run`, which provisions Python for you); a plain `python3` 3.11+ on your PATH still works during the transition. The script uses only stdlib `tomllib`, so there's nothing to `pip install`.
- A text editor for TOML files - A text editor for TOML files
::: :::
@ -201,15 +201,15 @@ persistent_facts = [
## How Resolution Works ## How Resolution Works
On activation, the agent's SKILL.md runs a shared Python script that does the three-layer merge and returns the resolved block as JSON. The script uses the Python standard library's `tomllib` module (no external dependencies), so plain `python3` is enough: On activation, the agent's SKILL.md runs a shared Python script that does the three-layer merge and returns the resolved block as JSON. The script uses only the Python standard library's `tomllib` module (no external dependencies). BMad is standardizing on `uv run` to invoke these scripts (uv provisions a suitable Python for you); a plain `python3` still works during the transition:
```bash ```bash
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill {skill-root} \ --skill {skill-root} \
--key agent --key agent
``` ```
**Requirements**: Python 3.11+ (earlier versions don't include `tomllib`). No `pip install`, no `uv`, no virtualenv. Check with `python3 --version`. Some platforms (macOS without Homebrew, Ubuntu 22.04) default `python3` to 3.10 or earlier, so you may need to install 3.11+ separately. **Requirements**: Python 3.11+ (earlier versions don't include `tomllib`); nothing to `pip install`. Running via `uv run` is the going-forward standard — uv resolves a suitable interpreter for you. If you run it with `python3` directly during the transition, check your version with `python3 --version`: some platforms (macOS without Homebrew, Ubuntu 22.04) default `python3` to 3.10 or earlier, so you may need to install 3.11+ separately.
`--skill` points at the skill's installed directory (where `customize.toml` lives). The skill name is derived from the directory's basename, and the script looks up `_bmad/custom/{skill-name}.toml` and `{skill-name}.user.toml` automatically. `--skill` points at the skill's installed directory (where `customize.toml` lives). The skill name is derived from the directory's basename, and the script looks up `_bmad/custom/{skill-name}.toml` and `{skill-name}.user.toml` automatically.
@ -217,17 +217,17 @@ Useful invocations:
```bash ```bash
# Resolve the full agent block # Resolve the full agent block
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /abs/path/to/bmad-agent-pm \ --skill /abs/path/to/bmad-agent-pm \
--key agent --key agent
# Resolve a single field # Resolve a single field
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /abs/path/to/bmad-agent-pm \ --skill /abs/path/to/bmad-agent-pm \
--key agent.icon --key agent.icon
# Full dump # Full dump
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /abs/path/to/bmad-agent-pm --skill /abs/path/to/bmad-agent-pm
``` ```

View File

@ -22,7 +22,7 @@ Skill `bmad-customize` là trợ lý tạo cấu hình có hướng dẫn cho **
:::note[Điều kiện tiên quyết] :::note[Điều kiện tiên quyết]
- BMad đã được cài trong dự án của bạn (xem [Cách cài đặt BMad](./install-bmad.md)) - BMad đã được cài trong dự án của bạn (xem [Cách cài đặt BMad](./install-bmad.md))
- Python 3.11+ có trên PATH của bạn (để chạy resolver; dùng stdlib `tomllib`, không cần `pip install`, `uv` hay virtualenv) - Một cách để chạy resolver script — BMad đang chuẩn hóa sang `uv` (`uv run`, tự cấp Python cho bạn); một `python3` 3.11+ thuần trên PATH vẫn dùng được trong giai đoạn chuyển đổi. Script chỉ dùng stdlib `tomllib`, nên không cần `pip install` gì cả.
- Một trình soạn thảo văn bản cho file TOML - Một trình soạn thảo văn bản cho file TOML
::: :::
@ -201,15 +201,15 @@ persistent_facts = [
## Cách quá trình resolve diễn ra ## Cách quá trình resolve diễn ra
Khi agent được kích hoạt, `SKILL.md` của nó sẽ gọi một shared Python script để merge ba lớp nói trên và trả về block kết quả ở dạng JSON. Script này dùng `tomllib` của Python stdlib, nên `python3` thuần là đủ: Khi agent được kích hoạt, `SKILL.md` của nó sẽ gọi một shared Python script để merge ba lớp nói trên và trả về block kết quả ở dạng JSON. Script này chỉ dùng `tomllib` của Python stdlib (không có dependency ngoài). BMad đang chuẩn hóa sang `uv run` để chạy các script này (uv tự cấp một bản Python phù hợp cho bạn); một `python3` thuần vẫn dùng được trong giai đoạn chuyển đổi:
```bash ```bash
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill {skill-root} \ --skill {skill-root} \
--key agent --key agent
``` ```
**Yêu cầu**: Python 3.11+ vì các phiên bản cũ hơn không có `tomllib`. Không cần `pip install`, không cần `uv`, không cần virtualenv. Bạn có thể kiểm tra bằng `python3 --version`. Trên một số nền tảng, `python3` mặc định vẫn là 3.10 hoặc thấp hơn, nên có thể bạn sẽ phải cài 3.11+ riêng. **Yêu cầu**: Python 3.11+ vì các phiên bản cũ hơn không có `tomllib`; không cần `pip install` gì. Chạy qua `uv run` là chuẩn về sau — uv tự tìm một bản interpreter phù hợp cho bạn. Nếu bạn chạy trực tiếp bằng `python3` trong giai đoạn chuyển đổi, hãy kiểm tra phiên bản bằng `python3 --version`: trên một số nền tảng, `python3` mặc định vẫn là 3.10 hoặc thấp hơn, nên có thể bạn sẽ phải cài 3.11+ riêng.
`--skill` trỏ vào thư mục skill đã cài, nơi có file `customize.toml`. Tên skill được lấy từ basename của thư mục, sau đó script sẽ tự tìm `_bmad/custom/{skill-name}.toml``{skill-name}.user.toml`. `--skill` trỏ vào thư mục skill đã cài, nơi có file `customize.toml`. Tên skill được lấy từ basename của thư mục, sau đó script sẽ tự tìm `_bmad/custom/{skill-name}.toml``{skill-name}.user.toml`.
@ -217,17 +217,17 @@ Một số lệnh hữu ích:
```bash ```bash
# Resolve toàn bộ block agent # Resolve toàn bộ block agent
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /duong-dan/tuyet-doi/toi/bmad-agent-pm \ --skill /duong-dan/tuyet-doi/toi/bmad-agent-pm \
--key agent --key agent
# Resolve một trường cụ thể # Resolve một trường cụ thể
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /duong-dan/tuyet-doi/toi/bmad-agent-pm \ --skill /duong-dan/tuyet-doi/toi/bmad-agent-pm \
--key agent.icon --key agent.icon
# Dump toàn bộ # Dump toàn bộ
python3 {project-root}/_bmad/scripts/resolve_customization.py \ uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill /duong-dan/tuyet-doi/toi/bmad-agent-pm --skill /duong-dan/tuyet-doi/toi/bmad-agent-pm
``` ```

View File

@ -207,11 +207,11 @@ def test_unknown_category_style_uses_fallback_glyph():
def test_shipped_selector_is_in_sync_with_catalog(): def test_shipped_selector_is_in_sync_with_catalog():
# foolproofing: if someone edits brain-methods.csv they must regenerate the page. # foolproofing: if someone edits brain-methods.csv they must regenerate the page.
# Regenerate with: python3 brain.py html --out assets/brain-selector.html # Regenerate with: uv run brain.py html --out assets/brain-selector.html
asset = brain.DEFAULT_FILE.parent / "brain-selector.html" asset = brain.DEFAULT_FILE.parent / "brain-selector.html"
assert asset.is_file(), "missing assets/brain-selector.html — generate it" assert asset.is_file(), "missing assets/brain-selector.html — generate it"
expected = brain.html_doc(brain.load(brain.DEFAULT_FILE)) expected = brain.html_doc(brain.load(brain.DEFAULT_FILE))
assert asset.read_text(encoding="utf-8") == expected, ( assert asset.read_text(encoding="utf-8") == expected, (
"assets/brain-selector.html is stale; regenerate: " "assets/brain-selector.html is stale; regenerate: "
"python3 brain.py html --out assets/brain-selector.html" "uv run brain.py html --out assets/brain-selector.html"
) )

View File

@ -13,7 +13,7 @@ Exercises the scanner against a synthesized install tree:
- malformed TOML (surfaces as an error without aborting) - malformed TOML (surfaces as an error without aborting)
- multiple skills roots (e.g. project-local + user-global mix) - multiple skills roots (e.g. project-local + user-global mix)
Run: python3 scripts/tests/test_list_customizable_skills.py Run: uv run scripts/tests/test_list_customizable_skills.py
""" """
from __future__ import annotations from __future__ import annotations

View File

@ -10,12 +10,14 @@ Reads from four layers (highest priority last):
Outputs merged JSON to stdout. Errors go to stderr. Outputs merged JSON to stdout. Errors go to stderr.
Requires Python 3.11+ (uses stdlib `tomllib`). No `uv`, no `pip install`, Uses only the Python stdlib (`tomllib`) no third-party dependencies.
no virtualenv plain `python3` is sufficient. BMad is standardizing on `uv run` to invoke scripts (uv provisions a suitable
interpreter for you); a plain `python3` on PATH still works during the
transition. Either runner needs Python 3.11+ for `tomllib`.
python3 resolve_config.py --project-root /abs/path/to/project uv run resolve_config.py --project-root /abs/path/to/project
python3 resolve_config.py --project-root ... --key core uv run resolve_config.py --project-root ... --key core
python3 resolve_config.py --project-root ... --key agents uv run resolve_config.py --project-root ... --key agents
Merge rules (same as resolve_customization.py): Merge rules (same as resolve_customization.py):
- Scalars: override wins - Scalars: override wins

View File

@ -11,12 +11,14 @@ Skill name is derived from the basename of the skill directory.
Outputs merged JSON to stdout. Errors go to stderr. Outputs merged JSON to stdout. Errors go to stderr.
Requires Python 3.11+ (uses stdlib `tomllib`). No `uv`, no `pip install`, Uses only the Python stdlib (`tomllib`) no third-party dependencies.
no virtualenv plain `python3` is sufficient. BMad is standardizing on `uv run` to invoke scripts (uv provisions a suitable
interpreter for you); a plain `python3` on PATH still works during the
transition. Either runner needs Python 3.11+ for `tomllib`.
python3 resolve_customization.py --skill /abs/path/to/skill-dir uv run resolve_customization.py --skill /abs/path/to/skill-dir
python3 resolve_customization.py --skill ... --key agent uv run resolve_customization.py --skill ... --key agent
python3 resolve_customization.py --skill ... --key agent.menu uv run resolve_customization.py --skill ... --key agent.menu
Merge rules (purely structural no field-name special-casing): Merge rules (purely structural no field-name special-casing):
- Scalars (string, int, bool, float): override wins - Scalars (string, int, bool, float): override wins

View File

@ -3319,134 +3319,63 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================ // ============================================================
// Test Suite 46: Python environment check (version parsing + classification) // Test Suite 46: uv environment check (version parsing + messaging)
// ============================================================ // ============================================================
console.log(`${colors.yellow}Test Suite 46: python-check version parsing and classification${colors.reset}\n`); console.log(`${colors.yellow}Test Suite 46: uv-check version parsing and messaging${colors.reset}\n`);
try { try {
const { parsePythonVersion, classifyPython, detectPython } = require('../tools/installer/core/python-check'); const { parseUvVersion, detectUv } = require('../tools/installer/core/uv-check');
// Version parsing // Version parsing
const v312 = parsePythonVersion('Python 3.12.1'); const plain = parseUvVersion('uv 0.5.31');
assert(v312 && v312.major === 3 && v312.minor === 12 && v312.patch === 1, 'parses "Python 3.12.1"'); assert(plain && plain.major === 0 && plain.minor === 5 && plain.patch === 31, 'parses "uv 0.5.31"');
const v311 = parsePythonVersion('Python 3.11.0\n'); const brew = parseUvVersion('uv 0.5.31 (Homebrew 2025-02-12)');
assert(v311 && v311.raw === '3.11.0', 'parses with trailing newline'); assert(brew && brew.raw === '0.5.31', 'parses uv version with build suffix');
const v2 = parsePythonVersion('\nPython 2.7.18'); const noPatch = parseUvVersion('uv 1.2');
assert(v2 && v2.major === 2, 'parses Python 2 output (stderr-style)');
const noPatch = parsePythonVersion('Python 3.13');
assert(noPatch && noPatch.patch === 0, 'missing patch defaults to 0'); assert(noPatch && noPatch.patch === 0, 'missing patch defaults to 0');
assert(parsePythonVersion('') === null, 'empty output returns null'); assert(parseUvVersion('') === null, 'empty output returns null');
assert(parsePythonVersion('command not found: python3') === null, 'non-version output returns null'); assert(parseUvVersion('command not found: uv') === null, 'non-version output returns null');
assert(parsePythonVersion(null) === null, 'null output returns null'); assert(parseUvVersion(null) === null, 'null output returns null');
// Classification against feature requirements // Detection smoke test — must not throw; result is null or well-formed.
assert(classifyPython({ major: 3, minor: 11 }) === 'full', '3.11 is full support (tomllib floor)'); const detectedUv = detectUv();
assert(classifyPython({ major: 3, minor: 13 }) === 'full', '3.13 is full support'); assert(detectedUv === null || typeof detectedUv.version.raw === 'string', 'detectUv returns null or a well-formed result');
assert(classifyPython({ major: 4, minor: 0 }) === 'full', 'hypothetical 4.0 is full support');
assert(classifyPython({ major: 3, minor: 10 }) === 'partial', '3.10 is partial (memlog yes, tomllib no)');
assert(classifyPython({ major: 3, minor: 8 }) === 'partial', '3.8 is partial (memlog floor)');
assert(classifyPython({ major: 3, minor: 7 }) === 'unsupported', '3.7 is unsupported');
assert(classifyPython({ major: 2, minor: 7 }) === 'unsupported', '2.7 is unsupported');
assert(classifyPython(null) === 'none', 'no python is none');
// Detection smoke test — must not throw, and if it finds a Python the // checkUvEnvironment branch coverage — stub detection + prompts so the
// result must be well-formed. (CI machines may or may not have Python.) // assertions are deterministic regardless of whether uv is installed.
const detected = detectPython(); const uvCheck = require('../tools/installer/core/uv-check');
assert(
detected === null ||
(typeof detected.command === 'string' &&
typeof detected.version.raw === 'string' &&
typeof detected.isRuntimeCommand === 'boolean'),
'detectPython returns null or a well-formed result',
);
// checkPythonEnvironment branch coverage — stub detection, prompts, and
// process.exit so the assertions are deterministic regardless of the
// machine's Python. python-check resolves detectPython via module.exports
// and prompts via the shared module object, so swapping properties works.
const pythonCheck = require('../tools/installer/core/python-check');
const promptsModule = require('../tools/installer/prompts'); const promptsModule = require('../tools/installer/prompts');
const real = { const realUv = { detectUv: uvCheck.detectUv, log: promptsModule.log, note: promptsModule.note };
detectPython: pythonCheck.detectPython, const stubUv = (detectResult) => {
log: promptsModule.log, const seen = { success: [], warn: [], note: [] };
note: promptsModule.note, uvCheck.detectUv = () => detectResult;
select: promptsModule.select,
cancel: promptsModule.cancel,
exit: process.exit,
};
const stub = (detectResult, selectAnswer) => {
const seen = { success: [], warn: [], info: [], note: [], select: [], cancel: [], exit: [] };
pythonCheck.detectPython = () => detectResult;
promptsModule.log = { promptsModule.log = {
success: async (m) => void seen.success.push(m), success: async (m) => void seen.success.push(m),
warn: async (m) => void seen.warn.push(m), warn: async (m) => void seen.warn.push(m),
info: async (m) => void seen.info.push(m), info: async () => {},
error: async () => {}, error: async () => {},
}; };
promptsModule.note = async (m, t) => void seen.note.push(t || m); promptsModule.note = async (m, t) => void seen.note.push(t || m);
promptsModule.select = async (opts) => {
seen.select.push(opts.message);
return selectAnswer;
};
promptsModule.cancel = async (m) => void seen.cancel.push(m);
process.exit = (code) => {
seen.exit.push(code);
throw new Error('__stub_exit__');
};
return seen; return seen;
}; };
try { try {
const v = (major, minor, patch) => ({ major, minor, patch, raw: `${major}.${minor}.${patch}` }); // Branch: uv present — success, no warning.
let seen = stubUv({ version: { major: 0, minor: 5, patch: 31, raw: '0.5.31' } });
let result = await uvCheck.checkUvEnvironment();
assert(result.status === 'found' && seen.success.length === 1, 'uv present logs success');
assert(seen.success[0].includes('uv run') && seen.warn.length === 0, 'uv present mentions uv run, no warning');
// Branch: full support via the runtime command — success, no prompt. // Branch: uv missing — warn + setup note, never blocks (no prompt).
let seen = stub({ command: 'python3', version: v(3, 12, 1), isRuntimeCommand: true }, 'continue'); seen = stubUv(null);
let result = await pythonCheck.checkPythonEnvironment(); result = await uvCheck.checkUvEnvironment();
assert(result.status === 'full' && seen.success.length === 1, 'full support via python3 logs success'); assert(result.status === 'missing' && seen.warn.length === 1, 'uv missing warns');
assert(seen.select.length === 0 && seen.warn.length === 0, 'full support via python3 skips warning and ack prompt'); assert(seen.warn[0].includes('de facto standard'), 'uv-missing warning frames uv as the de facto standard');
assert(seen.note.length === 1 && seen.note[0].includes('uv'), 'uv missing shows a setup note');
// Branch: modern Python found, but not as `python3` — runtime mismatch.
seen = stub({ command: 'py -3', version: v(3, 12, 0), isRuntimeCommand: false }, 'continue');
result = await pythonCheck.checkPythonEnvironment();
assert(seen.success.length === 0, 'python3-mismatch never reports full support');
assert(
seen.warn.length === 1 && seen.warn[0].includes('python3') && seen.warn[0].includes('py -3'),
'python3-mismatch warns that scripts invoke python3',
);
assert(seen.select.length === 1 && result.status === 'full', 'python3-mismatch still requires the ack prompt');
// Branch: partial support (3.83.10) — warn + ack, continue returns.
seen = stub({ command: 'python3', version: v(3, 9, 5), isRuntimeCommand: true }, 'continue');
result = await pythonCheck.checkPythonEnvironment();
assert(
result.status === 'partial' && seen.warn.length === 1 && seen.warn[0].includes('3.11+'),
'partial support warns about tomllib floor',
);
assert(seen.select.length === 1 && seen.exit.length === 0, 'partial support prompts and continue proceeds');
// Branch: no Python, non-interactive — warn + info, never prompts.
seen = stub(null, 'continue');
result = await pythonCheck.checkPythonEnvironment({ nonInteractive: true });
assert(result.status === 'none' && seen.warn[0].includes('No Python found'), 'non-interactive with no Python warns');
assert(seen.select.length === 0 && seen.info.length === 1, 'non-interactive skips the ack prompt and logs continuation');
// Branch: no Python, interactive, user quits — cancel message + exit 0.
seen = stub(null, 'quit');
let threw = false;
try {
await pythonCheck.checkPythonEnvironment();
} catch (error) {
threw = error.message === '__stub_exit__';
}
assert(threw && seen.exit.length === 1 && seen.exit[0] === 0, 'quit choice exits 0 (user-cancel convention)');
assert(seen.cancel.length === 1, 'quit choice shows the cancel guidance');
} finally { } finally {
pythonCheck.detectPython = real.detectPython; uvCheck.detectUv = realUv.detectUv;
promptsModule.log = real.log; promptsModule.log = realUv.log;
promptsModule.note = real.note; promptsModule.note = realUv.note;
promptsModule.select = real.select;
promptsModule.cancel = real.cancel;
process.exit = real.exit;
} }
} catch (error) { } catch (error) {
console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`); console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`);

View File

@ -1233,6 +1233,9 @@ class Installer {
` 1. Launch your AI agent from your project folder`, ` 1. Launch your AI agent from your project folder`,
` 2. Not sure what to do? Invoke the ${color.cyan('bmad-help')} skill and ask it what to do!`, ` 2. Not sure what to do? Invoke the ${color.cyan('bmad-help')} skill and ask it what to do!`,
'', '',
` ${color.cyan('Tip:')} BMAD workflows increasingly run Python scripts via ${color.cyan('uv run')} — uv is`,
` becoming the de facto standard. If you don't have it yet, ask your agent to set it up.`,
'',
` Blog, Docs and Guides: ${color.blue('https://bmadcode.com/')}`, ` Blog, Docs and Guides: ${color.blue('https://bmadcode.com/')}`,
` Community: ${color.blue('https://discord.gg/gk8jAdXWmj')}`, ` Community: ${color.blue('https://discord.gg/gk8jAdXWmj')}`,
); );

View File

@ -1,199 +0,0 @@
const { spawnSync } = require('node:child_process');
const prompts = require('../prompts');
// Python 3.11 added stdlib `tomllib` (PEP 680), which the shared scripts in
// src/scripts/ (resolve_config.py, resolve_customization.py) require to read
// BMAD's TOML config files. memlog.py is more lenient and runs on 3.8+.
const PYTHON_FULL_SUPPORT = { major: 3, minor: 11 };
const PYTHON_PARTIAL_SUPPORT = { major: 3, minor: 8 };
// Every runtime call site (skill steps, on_complete hooks) invokes a literal
// `python3`, so only that command's version vouches for BMAD features. The
// fallback probes exist to tell the user "Python is installed, but not under
// the name BMAD uses" instead of a misleading "No Python found".
const RUNTIME_COMMAND = 'python3';
const PROBE_CANDIDATES =
process.platform === 'win32'
? [
{ command: 'python3', args: ['--version'] },
{ command: 'py', args: ['-3', '--version'] },
{ command: 'python', args: ['--version'] },
]
: [
{ command: 'python3', args: ['--version'] },
{ command: 'python', args: ['--version'] },
];
/**
* Parse a `python --version` output line into version parts.
* Python 3 prints to stdout; Python 2 printed to stderr callers pass both.
* @param {string} output - Combined stdout/stderr from `python --version`
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
*/
function parsePythonVersion(output) {
if (!output) return null;
const match = output.match(/Python\s+(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3] || 0),
raw: `${match[1]}.${match[2]}.${match[3] || 0}`,
};
}
/**
* Classify a detected Python version against BMAD's feature requirements.
* @param {{major: number, minor: number}|null} version
* @returns {'full'|'partial'|'unsupported'|'none'}
*/
function classifyPython(version) {
if (!version) return 'none';
const { major, minor } = version;
if (major > PYTHON_FULL_SUPPORT.major || (major === PYTHON_FULL_SUPPORT.major && minor >= PYTHON_FULL_SUPPORT.minor)) {
return 'full';
}
if (major === PYTHON_PARTIAL_SUPPORT.major && minor >= PYTHON_PARTIAL_SUPPORT.minor) {
return 'partial';
}
return 'unsupported';
}
/**
* Run one probe candidate and return its parsed version, or null.
* @param {{command: string, args: string[]}} candidate
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
*/
function probeVersion(candidate) {
const run = (extra = {}) =>
spawnSync(candidate.command, candidate.args, {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
...extra,
});
let result = run();
// Node >=18.20/20.12 refuses to spawn .bat/.cmd without a shell
// (CVE-2024-27980 hardening) and reports EINVAL — pyenv-win ships its
// python shims as .bat. Args here are static literals, so a shell retry
// is injection-safe.
if (result.error && result.error.code === 'EINVAL' && process.platform === 'win32') {
result = run({ shell: true });
}
if (result.error) return null;
return parsePythonVersion(`${result.stdout || ''}\n${result.stderr || ''}`);
}
/**
* Probe the local environment for a Python interpreter.
* Tries each candidate command and returns the first that reports a version.
* `isRuntimeCommand` is true only when the match is `python3` the command
* BMAD scripts actually invoke.
* @returns {{command: string, version: {major: number, minor: number, patch: number, raw: string}, isRuntimeCommand: boolean}|null}
*/
function detectPython() {
for (const candidate of PROBE_CANDIDATES) {
try {
const version = probeVersion(candidate);
if (version) {
const display = candidate.args.length > 1 ? `${candidate.command} ${candidate.args.slice(0, -1).join(' ')}` : candidate.command;
return { command: display, version, isRuntimeCommand: candidate.command === RUNTIME_COMMAND };
}
} catch {
// Candidate not runnable — try the next one.
}
}
return null;
}
function upgradeHints() {
return [
'How to get Python 3.11+ (as `python3`):',
' macOS: brew install python3',
' Windows: winget install Python.Python.3.12 (then ensure `python3` resolves, e.g. enable the python3 alias)',
' Linux/WSL: sudo apt install python3 (Ubuntu 24.04+ ships 3.12; older distros: use pyenv or deadsnakes)',
' Docker: add python3 to your image (e.g. apk add python3 / apt-get install -y python3)',
].join('\n');
}
/**
* Check the local Python environment and warn about degraded BMAD features.
*
* Warn-don't-block: most of BMAD works without Python, so the install always
* may proceed but the user must explicitly acknowledge the warning so it
* can't scroll past unseen. In non-interactive runs (--yes, or stdin is not
* a TTY) the warning is logged and the install continues without a prompt.
*
* @param {Object} [options]
* @param {boolean} [options.nonInteractive=false] - Skip the ack prompt (--yes, or no TTY)
* @returns {Promise<{status: string, detected: Object|null}>}
*/
async function checkPythonEnvironment({ nonInteractive = false } = {}) {
// Called via module.exports so tests can stub detection.
const detected = module.exports.detectPython();
const status = classifyPython(detected ? detected.version : null);
if (status === 'full' && detected.isRuntimeCommand) {
await prompts.log.success(`Python ${detected.version.raw} detected (${detected.command}) — all BMAD features supported.`);
return { status, detected };
}
if (detected && !detected.isRuntimeCommand) {
await prompts.log.warn(
`Python ${detected.version.raw} found via \`${detected.command}\`, but BMAD scripts invoke \`python3\`, which is not on PATH.\n` +
`Python-powered features (memlog session memory, TOML config resolution) won't run until \`python3\` resolves —\n` +
`add a python3 alias/shim, or reinstall Python with the python3 launcher enabled.`,
);
} else if (status === 'partial') {
await prompts.log.warn(
`Python ${detected.version.raw} detected (${detected.command}) — BMAD's TOML config tools need Python 3.11+ (stdlib tomllib).\n` +
`Works: memlog session memory. Won't work: config/customization resolution scripts.`,
);
} else {
const found =
status === 'unsupported' ? `Python ${detected.version.raw} detected (${detected.command}) — too old.` : 'No Python found on PATH.';
await prompts.log.warn(
`${found} BMAD installs fine without it, but Python-powered features\n` +
`(memlog session memory, TOML config resolution) won't run until Python 3.11+ is available.`,
);
}
await prompts.note(upgradeHints(), 'Python 3.11+ recommended');
if (nonInteractive) {
await prompts.log.info('Continuing anyway (non-interactive run). You can fix Python later — no reinstall needed.');
return { status, detected };
}
const choice = await prompts.select({
message: "BMAD's Python-powered features won't work yet. How do you want to proceed?",
choices: [
{
name: 'Continue install',
value: 'continue',
hint: 'BMAD works without Python — you can fix Python later, no reinstall needed',
},
{
name: 'Quit and fix Python first',
value: 'quit',
hint: 'make Python 3.11+ available as python3, then re-run the installer',
},
],
default: 'continue',
});
if (choice === 'quit') {
await prompts.cancel('Make Python 3.11+ available as `python3` (see hints above), then re-run the installer.');
process.exit(0);
}
return { status, detected };
}
module.exports = {
checkPythonEnvironment,
detectPython,
parsePythonVersion,
classifyPython,
PYTHON_FULL_SUPPORT,
PYTHON_PARTIAL_SUPPORT,
};

View File

@ -0,0 +1,97 @@
const { spawnSync } = require('node:child_process');
const prompts = require('../prompts');
// `uv` (https://docs.astral.sh/uv/) is becoming the de facto standard for
// running the Python scripts BMAD workflows shell out to: `uv run <script>`
// resolves the interpreter and any dependencies on demand, so skills don't
// have to assume a particular `python3` is on PATH. The ecosystem is mid-
// migration — some skills still call `python3` directly — so a missing `uv`
// is a warning, not a blocker: BMAD installs and runs either way.
const RUNTIME_COMMAND = 'uv';
/**
* Parse `uv --version` output into version parts.
* Example outputs: "uv 0.5.31", "uv 0.5.31 (Homebrew 2025-02-12)".
* @param {string} output - stdout/stderr from `uv --version`
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
*/
function parseUvVersion(output) {
if (!output) return null;
const match = output.match(/uv\s+(\d+)\.(\d+)(?:\.(\d+))?/i);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3] || 0),
raw: `${match[1]}.${match[2]}.${match[3] || 0}`,
};
}
/**
* Probe the local environment for `uv`.
* @returns {{version: {major: number, minor: number, patch: number, raw: string}}|null}
*/
function detectUv() {
let result;
try {
result = spawnSync(RUNTIME_COMMAND, ['--version'], {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
});
} catch {
return null;
}
if (!result || result.error) return null;
const version = parseUvVersion(`${result.stdout || ''}\n${result.stderr || ''}`);
return version ? { version } : null;
}
function setupHints() {
return [
'BMAD workflows increasingly run Python scripts via `uv run`, which manages',
'the interpreter and dependencies for you — no manual venv or pip needed.',
'',
'Easiest path: ask your AI agent to "install and set up uv for me".',
'',
'Or install it yourself:',
' macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh',
' Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"',
' Homebrew: brew install uv',
' Docs: https://docs.astral.sh/uv/getting-started/installation/',
].join('\n');
}
/**
* Check whether `uv` is available and inform the user.
*
* Warn-don't-block, and no acknowledgement prompt: `uv` is on its way to being
* the standard runner for BMAD's Python scripts, but the migration is still in
* progress, so the install never stops on its account. The note tells the user
* how to set it up (preferably by asking their agent).
*
* @returns {Promise<{status: 'found'|'missing', detected: Object|null}>}
*/
async function checkUvEnvironment() {
// Called via module.exports so tests can stub detection.
const detected = module.exports.detectUv();
if (detected) {
await prompts.log.success(`uv ${detected.version.raw} detected — ready to run BMAD's Python-powered scripts via \`uv run\`.`);
return { status: 'found', detected };
}
await prompts.log.warn(
"uv not found on PATH. uv is becoming the de facto standard for running BMAD's Python\n" +
'scripts (`uv run <script>`), and it provisions the interpreter for you. BMAD installs\n' +
'fine without it, but setting up uv now keeps you ahead as workflows adopt it.',
);
await prompts.note(setupHints(), 'uv recommended');
return { status: 'missing', detected: null };
}
module.exports = {
checkUvEnvironment,
detectUv,
parseUvVersion,
};

View File

@ -12,6 +12,10 @@ startMessage: |
🌟 100% free. 100% open source. Always. 🌟 100% free. 100% open source. Always.
No paywalls. No gated content. Knowledge shared, not sold. No paywalls. No gated content. Knowledge shared, not sold.
🐍 HEADS UP: uv (https://docs.astral.sh/uv/) is becoming the de facto standard
for running the Python scripts BMAD workflows rely on (`uv run <script>`).
If it's not set up yet, ask your AI agent to "install and set up uv for me".
🌐 CONNECT: 🌐 CONNECT:
Website: https://bmadcode.com/ Website: https://bmadcode.com/
Discord: https://discord.gg/gk8jAdXWmj Discord: https://discord.gg/gk8jAdXWmj

View File

@ -161,15 +161,16 @@ class UI {
const messageLoader = new MessageLoader(); const messageLoader = new MessageLoader();
await messageLoader.displayStartMessage(); await messageLoader.displayStartMessage();
// Probe the local Python before any other prompts: several BMAD features // Probe for `uv` before any other prompts: it's becoming the de facto
// (memlog session memory, TOML config resolution) need Python 3.11+ at // runner for the Python scripts BMAD workflows shell out to
// runtime. Warn-don't-block, but require an explicit ack so the warning // (`uv run <script>`), and uv provisions the interpreter itself, so it's
// can't scroll past unseen. The installer runs in the destination // the single thing worth checking for. The migration is still in progress
// environment, so probing PATH here tests the right machine. // (some skills still call `python3` directly), so this is informational —
// Skip the ack when stdin isn't a TTY (CI/Docker/piped): clack's select // warn-don't-block, no ack prompt — and just points the user at setup
// on closed stdin resolves to cancel, which would silently exit 0. // (ideally "ask your agent to set up uv"). The installer runs in the
const { checkPythonEnvironment } = require('./core/python-check'); // destination environment, so probing PATH here tests the right machine.
await checkPythonEnvironment({ nonInteractive: !!options.yes || !process.stdin.isTTY }); const { checkUvEnvironment } = require('./core/uv-check');
await checkUvEnvironment();
// Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings // Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
// are surfaced immediately so the user sees them before any git ops run. // are surfaced immediately so the user sees them before any git ops run.