Compare commits

...

4 Commits

Author SHA1 Message Date
Oneby Wang f32d27c252
Merge 6c29b3300b into 560a2e3a6f 2026-06-12 15:22:34 +02:00
Brian 560a2e3a6f
feat: installer detects Python version and warns when 3.11+ (tomllib) is missing (#2466)
* feat: installer detects Python version and warns when 3.11+ (tomllib) is missing

Several BMAD features need Python at runtime: memlog (3.8+) and the TOML
config resolution scripts (3.11+ for stdlib tomllib). Users install into
varied environments (Linux, Windows, WSL, Docker) where Python may be
missing or too old, and previously only found out via runtime errors.

The installer now probes PATH at startup (py -3 / python3 / python) and
classifies the result: 3.11+ passes silently with a success line; 3.8-3.10
or missing/too-old Python gets a warning naming exactly which features
degrade, plus per-platform install hints. The warning requires an explicit
ack — continue (fix later, no reinstall needed) or quit and re-run after
installing Python. Warn-don't-block: most of BMAD works without Python, so
the install is never refused. In --yes mode the warning logs and continues
without prompting.

* fix: align Python check with runtime truth (python3) and harden edge cases

Review fixes for the installer Python check:

- Probe python3 first on all platforms: every runtime call site invokes a
  literal python3, so only that command vouches for BMAD features. Python
  found via py/python now gets an explicit mismatch warning instead of a
  false "all BMAD features supported".
- Treat closed/piped stdin as non-interactive (in addition to --yes) so
  scripted installs no longer silently exit 0 via clack's cancel path.
- Retry probes with shell:true on win32 EINVAL (CVE-2024-27980 hardening
  rejects .bat/.cmd shims like pyenv-win's without a shell).
- Add Suite 46 branch tests for checkPythonEnvironment with stubbed
  detection, prompts, and process.exit.
2026-06-11 21:27:52 -05:00
Brian 6c29b3300b
Merge branch 'main' into fix-dev-story-code-review 2026-06-08 22:43:48 -05:00
Oneby Wang 13fa255750 Fix dev-story and code-review bug 2026-06-02 20:09:22 +08:00
6 changed files with 378 additions and 36 deletions

View File

@ -42,7 +42,7 @@ Otherwise add: `Findings are listed above. No story file was provided, so nothin
### 4. Resolve decision-needed findings ### 4. Resolve decision-needed findings
If `decision_needed` findings exist, present each one with its detail and the options available. The user must decide — the correct fix is ambiguous without their input. Walk through each finding (or batch related ones) and get the user's call. Once resolved, each becomes a `patch`, `defer`, or is dismissed. If `decision-needed` findings exist, present each one with its detail and the options available. The user must decide — the correct fix is ambiguous without their input. Walk through each finding (or batch related ones) and get the user's call. Once resolved, each becomes a `patch`, `defer`, or is dismissed.
If the user chooses to defer, ask: Quick one-line reason for deferring this item? (helps future reviews): — then append that reason to both the story file bullet and the `{deferred_work_file}` entry. If the user chooses to defer, ask: Quick one-line reason for deferring this item? (helps future reviews): — then append that reason to both the story file bullet and the `{deferred_work_file}` entry.
@ -87,7 +87,7 @@ Skip this section if `{spec_file}` is not set.
#### Determine new status based on review outcome #### Determine new status based on review outcome
- If all `decision-needed` and `patch` findings were resolved (fixed or dismissed) AND no unresolved HIGH/MEDIUM issues remain: set `{new_status}` = `done`. Update the story file Status section to `done`. - If all `decision-needed` and `patch` findings were resolved (fixed or dismissed) AND no unresolved HIGH/MEDIUM issues remain: set `{new_status}` = `done`. Update the story file Status section to `done`.
- If `patch` findings were left as action items, or unresolved issues remain: set `{new_status}` = `in-progress`. Update the story file Status section to `in-progress`. - If `patch` findings were left as action items, or unresolved issues remain: set `{new_status}` = `ready-for-dev`. Update the story file Status section to `ready-for-dev`.
Save the story file. Save the story file.
@ -119,7 +119,7 @@ If `{sprint_status}` file does not exist, note that story status was updated in
Present the user with follow-up options: Present the user with follow-up options:
> **What would you like to do next?** > **What would you like to do next?**
> 1. **Start the next story** — run `dev-story` to pick up the next `ready-for-dev` story > 1. **Continue development** — run `dev-story` to pick up the reviewed story or the next `ready-for-dev` story
> 2. **Re-run code review** — address findings and review again > 2. **Re-run code review** — address findings and review again
> 3. **Done** — end the workflow > 3. **Done** — end the workflow

View File

@ -10,7 +10,7 @@ description: 'Execute story implementation following a context filled story spec
**Your Role:** Developer implementing the story. **Your Role:** Developer implementing the story.
- Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level} - Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level}
- Generate all documents in {document_output_language} - Generate all documents in {document_output_language}
- Only modify the story file in these areas: YAML frontmatter `baseline_commit`, Tasks/Subtasks checkboxes, Dev Agent Record (Debug Log, Completion Notes), File List, Change Log, and Status - Only modify the story file in these areas: YAML frontmatter `baseline_commit`, Tasks/Subtasks checkboxes, Review Findings checkboxes, Dev Agent Record (Debug Log, Completion Notes), File List, Change Log, and Status
- Execute ALL steps in exact order; do NOT skip steps - Execute ALL steps in exact order; do NOT skip steps
- Absolutely DO NOT stop because of "milestones", "significant progress", or "session boundaries". Continue in a single execution until the story is COMPLETE (all ACs satisfied and all tasks/subtasks checked) UNLESS a HALT condition is triggered or the USER gives other instruction. - Absolutely DO NOT stop because of "milestones", "significant progress", or "session boundaries". Continue in a single execution until the story is COMPLETE (all ACs satisfied and all tasks/subtasks checked) UNLESS a HALT condition is triggered or the USER gives other instruction.
- Do NOT schedule a "next session" or request review pauses unless a HALT condition applies. Only Step 9 decides completion. - Do NOT schedule a "next session" or request review pauses unless a HALT condition applies. Only Step 9 decides completion.
@ -76,7 +76,7 @@ Activation is complete. If `activation_steps_prepend` or `activation_steps_appen
<workflow> <workflow>
<critical>Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level}</critical> <critical>Communicate all responses in {communication_language} and language MUST be tailored to {user_skill_level}</critical>
<critical>Generate all documents in {document_output_language}</critical> <critical>Generate all documents in {document_output_language}</critical>
<critical>Only modify the story file in these areas: YAML frontmatter `baseline_commit`, Tasks/Subtasks checkboxes, Dev Agent Record (Debug Log, Completion Notes), File List, <critical>Only modify the story file in these areas: YAML frontmatter `baseline_commit`, Tasks/Subtasks checkboxes, Review Findings checkboxes, Dev Agent Record (Debug Log, Completion Notes), File List,
Change Log, and Status</critical> Change Log, and Status</critical>
<critical>Execute ALL steps in exact order; do NOT skip steps</critical> <critical>Execute ALL steps in exact order; do NOT skip steps</critical>
<critical>Absolutely DO NOT stop because of "milestones", "significant progress", or "session boundaries". Continue in a single execution <critical>Absolutely DO NOT stop because of "milestones", "significant progress", or "session boundaries". Continue in a single execution
@ -223,31 +223,33 @@ Activation is complete. If `activation_steps_prepend` or `activation_steps_appen
<step n="3" goal="Detect review continuation and extract review context"> <step n="3" goal="Detect review continuation and extract review context">
<critical>Determine if this is a fresh start or continuation after code review</critical> <critical>Determine if this is a fresh start or continuation after code review</critical>
<action>Check if "Senior Developer Review (AI)" section exists in the story file</action> <action>Check if a "Review Findings" subsection exists under Tasks/Subtasks</action>
<action>Check if "Review Follow-ups (AI)" subsection exists under Tasks/Subtasks</action> <action>Count unchecked [ ] items marked "[Review][Decision]" in "Review Findings"</action>
<action>Count unchecked [ ] items marked "[Review][Patch]" in "Review Findings"</action>
<check if="Senior Developer Review section exists"> <check if="unchecked [Review][Decision] items exist">
<output>⚠️ **Review Decision Required**
This story still has unresolved `[Review][Decision]` findings. These require user/code-review workflow decisions before development can continue.
Return to `code-review` or resolve the decision items into patch, defer, or dismiss outcomes before running `dev-story` again.
</output>
<action>HALT - unresolved review decisions must be resolved before development</action>
</check>
<check if="unchecked [Review][Patch] items exist">
<action>Set review_continuation = true</action> <action>Set review_continuation = true</action>
<action>Extract from "Senior Developer Review (AI)" section: <action>Store list of unchecked [Review][Patch] items as {{pending_review_items}}</action>
- Review outcome (Approve/Changes Requested/Blocked)
- Review date
- Total action items with checkboxes (count checked vs unchecked)
- Severity breakdown (High/Med/Low counts)
</action>
<action>Count unchecked [ ] review follow-up tasks in "Review Follow-ups (AI)" subsection</action>
<action>Store list of unchecked review items as {{pending_review_items}}</action>
<output>⏯️ **Resuming Story After Code Review** ({{review_date}}) <output>⏯️ **Resuming Story After Code Review**
**Review Outcome:** {{review_outcome}} **Review Findings:** {{unchecked_review_count}} remaining to address
**Action Items:** {{unchecked_review_count}} remaining to address
**Priorities:** {{high_count}} High, {{med_count}} Medium, {{low_count}} Low
**Strategy:** Will prioritize review follow-up tasks (marked [AI-Review]) before continuing with regular tasks. **Strategy:** Will prioritize Review Findings tasks marked `[Review][Patch]` before continuing with regular tasks.
</output> </output>
</check> </check>
<check if="Senior Developer Review section does NOT exist"> <check if="no unchecked [Review][Patch] items exist">
<action>Set review_continuation = false</action> <action>Set review_continuation = false</action>
<action>Set {{pending_review_items}} = empty</action> <action>Set {{pending_review_items}} = empty</action>
@ -366,24 +368,17 @@ Activation is complete. If `activation_steps_prepend` or `activation_steps_appen
<action>Validate that ALL acceptance criteria related to this task are satisfied</action> <action>Validate that ALL acceptance criteria related to this task are satisfied</action>
<action>Run full test suite to ensure NO regressions introduced</action> <action>Run full test suite to ensure NO regressions introduced</action>
<!-- REVIEW FOLLOW-UP HANDLING --> <!-- REVIEW FINDINGS HANDLING -->
<check if="task is review follow-up (has [AI-Review] prefix)"> <check if="task is a review finding (has [Review][Patch] marker)">
<action>Extract review item details (severity, description, related AC/file)</action> <action>Extract review item details (description and related file/line if present)</action>
<action>Add to resolution tracking list: {{resolved_review_items}}</action> <action>Add to resolution tracking list: {{resolved_review_items}}</action>
<action>Prepare review finding completion note: "✅ Resolved review finding: {{description}}"</action>
<!-- Mark task in Review Follow-ups section -->
<action>Mark task checkbox [x] in "Tasks/Subtasks → Review Follow-ups (AI)" section</action>
<!-- CRITICAL: Also mark corresponding action item in review section -->
<action>Find matching action item in "Senior Developer Review (AI) → Action Items" section by matching description</action>
<action>Mark that action item checkbox [x] as resolved</action>
<action>Add to Dev Agent Record → Completion Notes: "✅ Resolved review finding [{{severity}}]: {{description}}"</action>
</check> </check>
<!-- ONLY MARK COMPLETE IF ALL VALIDATION PASS --> <!-- ONLY MARK COMPLETE IF ALL VALIDATION PASS -->
<check if="ALL validation gates pass AND tests ACTUALLY exist and pass"> <check if="ALL validation gates pass AND tests ACTUALLY exist and pass">
<action>ONLY THEN mark the task (and subtasks) checkbox with [x]</action> <action>ONLY THEN mark the task (and subtasks) checkbox with [x]</action>
<action>If this is a review finding task, this marks the item in "Tasks/Subtasks → Review Findings" and records the prepared review finding completion note</action>
<action>Update File List section with ALL new, modified, or deleted files (paths relative to repo root)</action> <action>Update File List section with ALL new, modified, or deleted files (paths relative to repo root)</action>
<action>Add completion notes to Dev Agent Record summarizing what was ACTUALLY implemented and tested</action> <action>Add completion notes to Dev Agent Record summarizing what was ACTUALLY implemented and tested</action>
</check> </check>

View File

@ -12,7 +12,7 @@ optional-inputs:
- 'CI logs' - 'CI logs'
- 'Linting reports' - 'Linting reports'
validation-rules: validation-rules:
- 'Only permitted story sections modified: Tasks/Subtasks checkboxes, Dev Agent Record, File List, Change Log, Status' - 'Only permitted story sections modified: Tasks/Subtasks checkboxes, Review Findings checkboxes, Dev Agent Record, File List, Change Log, Status'
- 'All implementation requirements from story Dev Notes must be satisfied' - 'All implementation requirements from story Dev Notes must be satisfied'
- 'Definition of Done checklist must pass completely' - 'Definition of Done checklist must pass completely'
- 'Enhanced story context must contain sufficient technical guidance' - 'Enhanced story context must contain sufficient technical guidance'
@ -52,7 +52,7 @@ validation-rules:
- [ ] **File List Complete:** File List includes EVERY new, modified, or deleted file (paths relative to repo root) - [ ] **File List Complete:** File List includes EVERY new, modified, or deleted file (paths relative to repo root)
- [ ] **Dev Agent Record Updated:** Contains relevant Implementation Notes and/or Debug Log for this work - [ ] **Dev Agent Record Updated:** Contains relevant Implementation Notes and/or Debug Log for this work
- [ ] **Change Log Updated:** Change Log includes clear summary of what changed and why - [ ] **Change Log Updated:** Change Log includes clear summary of what changed and why
- [ ] **Review Follow-ups:** All review follow-up tasks (marked [AI-Review]) completed and corresponding review items marked resolved (if applicable) - [ ] **Review Findings:** All review findings tasks (marked [Review][Patch]) completed and corresponding review items marked resolved (if applicable)
- [ ] **Story Structure Compliance:** Only permitted sections of story file were modified - [ ] **Story Structure Compliance:** Only permitted sections of story file were modified
## 🔚 Final Status Verification ## 🔚 Final Status Verification

View File

@ -3318,6 +3318,144 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 46: Python environment check (version parsing + classification)
// ============================================================
console.log(`${colors.yellow}Test Suite 46: python-check version parsing and classification${colors.reset}\n`);
try {
const { parsePythonVersion, classifyPython, detectPython } = require('../tools/installer/core/python-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');
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');
// 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, 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');
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;
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),
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: 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');
} 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;
}
} catch (error) {
console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -0,0 +1,199 @@
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

@ -161,6 +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
// (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 });
// 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.
const channelOptions = parseChannelOptions(options); const channelOptions = parseChannelOptions(options);