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]
- 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
:::
@ -201,15 +201,15 @@ persistent_facts = [
## 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
python3 {project-root}/_bmad/scripts/resolve_customization.py \
uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill {skill-root} \
--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`.
@ -217,17 +217,17 @@ Exemples dutilisation :
```bash
# 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 \
--key agent
# 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 \
--key agent.icon
# 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
```

View File

@ -22,7 +22,7 @@ The `bmad-customize` skill is a guided authoring helper for the **per-skill agen
:::note[Prerequisites]
- 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
:::
@ -201,15 +201,15 @@ persistent_facts = [
## 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
python3 {project-root}/_bmad/scripts/resolve_customization.py \
uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill {skill-root} \
--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.
@ -217,17 +217,17 @@ Useful invocations:
```bash
# 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 \
--key agent
# 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 \
--key agent.icon
# 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
```

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]
- 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
:::
@ -201,15 +201,15 @@ persistent_facts = [
## 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
python3 {project-root}/_bmad/scripts/resolve_customization.py \
uv run {project-root}/_bmad/scripts/resolve_customization.py \
--skill {skill-root} \
--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`.
@ -217,17 +217,17 @@ Một số lệnh hữu ích:
```bash
# 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 \
--key agent
# 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 \
--key agent.icon
# 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
```

View File

@ -207,11 +207,11 @@ def test_unknown_category_style_uses_fallback_glyph():
def test_shipped_selector_is_in_sync_with_catalog():
# 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"
assert asset.is_file(), "missing assets/brain-selector.html — generate it"
expected = brain.html_doc(brain.load(brain.DEFAULT_FILE))
assert asset.read_text(encoding="utf-8") == expected, (
"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)
- 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

View File

@ -10,12 +10,14 @@ Reads from four layers (highest priority last):
Outputs merged JSON to stdout. Errors go to stderr.
Requires Python 3.11+ (uses stdlib `tomllib`). No `uv`, no `pip install`,
no virtualenv plain `python3` is sufficient.
Uses only the Python stdlib (`tomllib`) no third-party dependencies.
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
python3 resolve_config.py --project-root ... --key core
python3 resolve_config.py --project-root ... --key agents
uv run resolve_config.py --project-root /abs/path/to/project
uv run resolve_config.py --project-root ... --key core
uv run resolve_config.py --project-root ... --key agents
Merge rules (same as resolve_customization.py):
- 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.
Requires Python 3.11+ (uses stdlib `tomllib`). No `uv`, no `pip install`,
no virtualenv plain `python3` is sufficient.
Uses only the Python stdlib (`tomllib`) no third-party dependencies.
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
python3 resolve_customization.py --skill ... --key agent
python3 resolve_customization.py --skill ... --key agent.menu
uv run resolve_customization.py --skill /abs/path/to/skill-dir
uv run resolve_customization.py --skill ... --key agent
uv run resolve_customization.py --skill ... --key agent.menu
Merge rules (purely structural no field-name special-casing):
- Scalars (string, int, bool, float): override wins

View File

@ -3319,134 +3319,63 @@ async function runTests() {
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 {
const { parsePythonVersion, classifyPython, detectPython } = require('../tools/installer/core/python-check');
const { parseUvVersion, detectUv } = require('../tools/installer/core/uv-check');
// Version parsing
const v312 = parsePythonVersion('Python 3.12.1');
assert(v312 && v312.major === 3 && v312.minor === 12 && v312.patch === 1, 'parses "Python 3.12.1"');
const v311 = parsePythonVersion('Python 3.11.0\n');
assert(v311 && v311.raw === '3.11.0', 'parses with trailing newline');
const v2 = parsePythonVersion('\nPython 2.7.18');
assert(v2 && v2.major === 2, 'parses Python 2 output (stderr-style)');
const noPatch = parsePythonVersion('Python 3.13');
const plain = parseUvVersion('uv 0.5.31');
assert(plain && plain.major === 0 && plain.minor === 5 && plain.patch === 31, 'parses "uv 0.5.31"');
const brew = parseUvVersion('uv 0.5.31 (Homebrew 2025-02-12)');
assert(brew && brew.raw === '0.5.31', 'parses uv version with build suffix');
const noPatch = parseUvVersion('uv 1.2');
assert(noPatch && noPatch.patch === 0, 'missing patch defaults to 0');
assert(parsePythonVersion('') === null, 'empty output returns null');
assert(parsePythonVersion('command not found: python3') === null, 'non-version output returns null');
assert(parsePythonVersion(null) === null, 'null output returns null');
assert(parseUvVersion('') === null, 'empty output returns null');
assert(parseUvVersion('command not found: uv') === null, 'non-version output returns null');
assert(parseUvVersion(null) === null, 'null output returns null');
// Classification against feature requirements
assert(classifyPython({ major: 3, minor: 11 }) === 'full', '3.11 is full support (tomllib floor)');
assert(classifyPython({ major: 3, minor: 13 }) === 'full', '3.13 is full support');
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; result is null or well-formed.
const detectedUv = detectUv();
assert(detectedUv === null || typeof detectedUv.version.raw === 'string', 'detectUv returns null or a well-formed result');
// Detection smoke test — must not throw, and if it finds a Python the
// result must be well-formed. (CI machines may or may not have Python.)
const detected = detectPython();
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');
// checkUvEnvironment branch coverage — stub detection + prompts so the
// assertions are deterministic regardless of whether uv is installed.
const uvCheck = require('../tools/installer/core/uv-check');
const promptsModule = require('../tools/installer/prompts');
const real = {
detectPython: pythonCheck.detectPython,
log: promptsModule.log,
note: promptsModule.note,
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;
const realUv = { detectUv: uvCheck.detectUv, log: promptsModule.log, note: promptsModule.note };
const stubUv = (detectResult) => {
const seen = { success: [], warn: [], note: [] };
uvCheck.detectUv = () => detectResult;
promptsModule.log = {
success: async (m) => void seen.success.push(m),
warn: async (m) => void seen.warn.push(m),
info: async (m) => void seen.info.push(m),
info: async () => {},
error: async () => {},
};
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;
};
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.
let seen = stub({ command: 'python3', version: v(3, 12, 1), isRuntimeCommand: true }, 'continue');
let result = await pythonCheck.checkPythonEnvironment();
assert(result.status === 'full' && seen.success.length === 1, 'full support via python3 logs success');
assert(seen.select.length === 0 && seen.warn.length === 0, 'full support via python3 skips warning and ack prompt');
// 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');
// Branch: uv missing — warn + setup note, never blocks (no prompt).
seen = stubUv(null);
result = await uvCheck.checkUvEnvironment();
assert(result.status === 'missing' && seen.warn.length === 1, 'uv missing warns');
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');
} finally {
pythonCheck.detectPython = real.detectPython;
promptsModule.log = real.log;
promptsModule.note = real.note;
promptsModule.select = real.select;
promptsModule.cancel = real.cancel;
process.exit = real.exit;
uvCheck.detectUv = realUv.detectUv;
promptsModule.log = realUv.log;
promptsModule.note = realUv.note;
}
} catch (error) {
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`,
` 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/')}`,
` 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.
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:
Website: https://bmadcode.com/
Discord: https://discord.gg/gk8jAdXWmj

View File

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