@jaguilar87/gaia-ops 5.0.0-beta.3 → 5.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "gaia-security",
10
10
  "description": "Keeps you in the loop only when it matters. Gaia Security analyzes every command and classifies it into risk tiers: read-only queries run freely, simulations and validations pass through, and state-changing operations (create, delete, apply, push) pause for your explicit approval before executing. Irreversible commands like dropping databases or deleting cloud infrastructure are permanently blocked.",
11
- "version": "5.0.0-beta.3",
11
+ "version": "5.0.0-beta.4",
12
12
  "source": "./dist/gaia-security"
13
13
  }
14
14
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.0-beta.3",
3
+ "version": "5.0.0-beta.4",
4
4
  "description": "Security-first orchestrator with specialized agents, hooks, and governance for AI coding",
5
5
  "author": {
6
6
  "name": "jaguilar87"
@@ -18,6 +18,7 @@ import fs from 'fs/promises';
18
18
  import { existsSync } from 'fs';
19
19
  import { exec } from 'child_process';
20
20
  import { promisify } from 'util';
21
+ import { findPython } from './python-detect.js';
21
22
  import chalk from 'chalk';
22
23
  import yargs from 'yargs';
23
24
  import { hideBin } from 'yargs/helpers';
@@ -200,8 +201,13 @@ async function checkProjectContext() {
200
201
  }
201
202
 
202
203
  async function checkPython() {
204
+ const pyCmd = findPython();
205
+ if (!pyCmd) {
206
+ return { name: 'Python', ok: false, detail: 'Not found', fix: 'Install Python 3.9+' };
207
+ }
208
+
203
209
  try {
204
- const { stdout } = await execAsync('python3 --version');
210
+ const { stdout } = await execAsync(`${pyCmd} --version`);
205
211
  const version = stdout.trim();
206
212
  const match = version.match(/(\d+)\.(\d+)/);
207
213
 
@@ -18,6 +18,7 @@
18
18
  import { execSync } from 'child_process';
19
19
  import { existsSync } from 'fs';
20
20
  import { join } from 'path';
21
+ import { findPython } from './python-detect.js';
21
22
  import chalk from 'chalk';
22
23
  import yargs from 'yargs';
23
24
  import { hideBin } from 'yargs/helpers';
@@ -49,7 +50,8 @@ function callReviewEngine(action, opts = {}) {
49
50
  process.exit(1);
50
51
  }
51
52
 
52
- let cmd = `python3 "${enginePath}" ${action}`;
53
+ const pyCmd = findPython() || 'python3';
54
+ let cmd = `${pyCmd} "${enginePath}" ${action}`;
53
55
  if (opts.updateId) cmd += ` --update-id "${opts.updateId}"`;
54
56
  if (opts.contextPath) cmd += ` --context-path "${opts.contextPath}"`;
55
57
  cmd += ' --json';
package/bin/gaia-scan CHANGED
@@ -12,6 +12,7 @@
12
12
  import { execFileSync } from 'child_process';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { dirname, join } from 'path';
15
+ import { requirePython } from './python-detect.js';
15
16
 
16
17
  const __filename = fileURLToPath(import.meta.url);
17
18
  const __dirname = dirname(__filename);
@@ -28,11 +29,16 @@ const pythonPath = process.env.PYTHONPATH
28
29
  : pluginRoot;
29
30
 
30
31
  try {
31
- execFileSync('python3', [scanScript, ...args], {
32
+ const pyCmd = requirePython();
33
+ execFileSync(pyCmd, [scanScript, ...args], {
32
34
  stdio: 'inherit',
33
35
  env: { ...process.env, PYTHONPATH: pythonPath },
34
36
  });
35
37
  } catch (error) {
38
+ if (error.message && error.message.includes('Python 3 not found')) {
39
+ console.error(error.message);
40
+ process.exit(1);
41
+ }
36
42
  // execFileSync throws on non-zero exit; propagate the exit code
37
43
  process.exit(error.status || 1);
38
44
  }
@@ -18,6 +18,7 @@
18
18
 
19
19
  import fs from "fs";
20
20
  import path from "path";
21
+ import { findPython } from "./python-detect.js";
21
22
  import { spawnSync } from "child_process";
22
23
  import { fileURLToPath } from "url";
23
24
  import chalk from "chalk";
@@ -728,7 +729,8 @@ function runTestProbe(ctx, findings, checks) {
728
729
  "Phase1SkillsInjection or load_skills",
729
730
  ];
730
731
 
731
- const res = spawnSync("python3", args, {
732
+ const pyCmd = findPython() || "python3";
733
+ const res = spawnSync(pyCmd, args, {
732
734
  cwd: ctx.packageRoot,
733
735
  encoding: "utf-8",
734
736
  });
@@ -741,7 +743,7 @@ function runTestProbe(ctx, findings, checks) {
741
743
  code: "PYTEST_PROBE_EXEC_FAILED",
742
744
  title: "Unable to execute pytest probe",
743
745
  detail: res.error.message,
744
- evidence: `python3 ${args.join(" ")}`,
746
+ evidence: `${pyCmd} ${args.join(" ")}`,
745
747
  remediation: "Install pytest and Python dependencies to run the probe.",
746
748
  });
747
749
  checks.push({ name: "test-probe", ok: false, detail: "execution failed" });
@@ -37,6 +37,7 @@ import { exec } from 'child_process';
37
37
  import { promisify } from 'util';
38
38
  import chalk from 'chalk';
39
39
  import ora from 'ora';
40
+ import { findPython } from './python-detect.js';
40
41
 
41
42
  const execAsync = promisify(exec);
42
43
  const __filename = fileURLToPath(import.meta.url);
@@ -126,8 +127,9 @@ async function updateLocalPermissions() {
126
127
  let gaiaPerms;
127
128
  try {
128
129
  const setupPath = join(__dirname, '..', 'hooks', 'modules', 'core', 'plugin_setup.py');
130
+ const pythonCmd = findPython() || 'python3';
129
131
  const { stdout } = await execAsync(
130
- `python3 -c "
132
+ `${pythonCmd} -c "
131
133
  import ast, json, re
132
134
 
133
135
  source = open('${setupPath.replace(/'/g, "\\'")}').read()
@@ -453,13 +455,16 @@ async function runVerification() {
453
455
  }
454
456
  }
455
457
 
456
- // 2. Python available
457
- try {
458
- const { stdout } = await execAsync('python3 --version', { timeout: 5000 });
459
- checks.push({ name: 'python3', ok: true, detail: stdout.trim() });
460
- } catch {
461
- checks.push({ name: 'python3', ok: false });
462
- issues.push('Python 3 not found (required for hooks)');
458
+ // 2. Python available (try python3 first, fall back to python on Windows)
459
+ {
460
+ const pyCmd = findPython();
461
+ if (pyCmd) {
462
+ const { stdout } = await execAsync(`${pyCmd} --version`, { timeout: 5000 });
463
+ checks.push({ name: 'python3', ok: true, detail: stdout.trim() });
464
+ } else {
465
+ checks.push({ name: 'python3', ok: false });
466
+ issues.push('Python 3 not found (required for hooks)');
467
+ }
463
468
  }
464
469
 
465
470
  // 3. project-context.json exists and is valid
@@ -540,12 +545,12 @@ async function runFreshInstall() {
540
545
 
541
546
  console.log(chalk.cyan(`\n gaia-ops ${chalk.green(current)} — fresh install\n`));
542
547
 
543
- // 1. Check Python 3 is available
548
+ // 1. Check Python 3 is available (try python3, then python)
544
549
  const spinner = ora('Checking Python 3...').start();
545
- try {
546
- await execAsync('python3 --version', { timeout: 5000 });
547
- spinner.succeed('Python 3 found');
548
- } catch {
550
+ const pyCmd = findPython();
551
+ if (pyCmd) {
552
+ spinner.succeed(`Python 3 found (${pyCmd})`);
553
+ } else {
549
554
  spinner.warn('Python 3 not found — skipping project setup');
550
555
  console.log(chalk.gray(' Install Python 3.9+ and run: npx gaia-scan\n'));
551
556
  return;
@@ -555,7 +560,7 @@ async function runFreshInstall() {
555
560
  const scanSpinner = ora('Running gaia-scan...').start();
556
561
  try {
557
562
  const { stdout, stderr } = await execAsync(
558
- `python3 "${scanScript}" --npm-postinstall --root "${CWD}"`,
563
+ `${pyCmd} "${scanScript}" --npm-postinstall --root "${CWD}"`,
559
564
  { timeout: 60000 }
560
565
  );
561
566
  scanSpinner.succeed('Project scanned and configured');
@@ -21,6 +21,7 @@ import path from 'path';
21
21
  import { execSync } from 'child_process';
22
22
  import chalk from 'chalk';
23
23
  import { fileURLToPath } from 'url';
24
+ import { findPython } from './python-detect.js';
24
25
 
25
26
  const __filename = fileURLToPath(import.meta.url);
26
27
  const __dirname = path.dirname(__filename);
@@ -371,15 +372,17 @@ class PrePublishValidator {
371
372
  'tools/context/context_provider.py'
372
373
  ];
373
374
 
375
+ const pyCmd = findPython();
376
+ if (!pyCmd) throw new Error('Python not available');
374
377
  pythonFiles.forEach(file => {
375
378
  const filePath = path.join(baseDir, file);
376
379
  if (fs.existsSync(filePath)) {
377
- this.execute(`python3 -m py_compile "${filePath}"`, GAIA_OPS_ROOT, true);
380
+ this.execute(`${pyCmd} -m py_compile "${filePath}"`, GAIA_OPS_ROOT, true);
378
381
  this.log(` ✓ ${file}`, 'success');
379
382
  }
380
383
  });
381
384
  } catch (error) {
382
- this.log(` ⚠️ Python validation skipped (python3 not available or syntax error)`, 'warning');
385
+ this.log(` ⚠️ Python validation skipped (python3/python not available or syntax error)`, 'warning');
383
386
  }
384
387
 
385
388
  // Test 3: Check if bin scripts are executable
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Cross-platform Python 3 detection.
3
+ *
4
+ * On Linux/macOS the binary is typically `python3`.
5
+ * On Windows it is often just `python` (the Microsoft Store alias
6
+ * or the official installer both register `python`).
7
+ *
8
+ * This module tries each candidate in order and returns the first
9
+ * one that reports Python 3.x. The result is cached for the
10
+ * lifetime of the process so repeated calls are free.
11
+ */
12
+
13
+ import { execSync, execFileSync } from 'child_process';
14
+
15
+ /** @type {string | null | undefined} */
16
+ let _cached;
17
+
18
+ /**
19
+ * Detect a working Python 3 command.
20
+ *
21
+ * @returns {string | null} The command name ('python3' or 'python'),
22
+ * or null if no Python 3 was found.
23
+ */
24
+ export function findPython() {
25
+ if (_cached !== undefined) return _cached;
26
+
27
+ for (const cmd of ['python3', 'python']) {
28
+ try {
29
+ const version = execFileSync(cmd, ['--version'], {
30
+ encoding: 'utf8',
31
+ stdio: ['pipe', 'pipe', 'pipe'],
32
+ timeout: 5000,
33
+ }).trim();
34
+ if (version.startsWith('Python 3.')) {
35
+ _cached = cmd;
36
+ return _cached;
37
+ }
38
+ } catch {
39
+ // Not found or not Python 3 -- try next candidate
40
+ }
41
+ }
42
+
43
+ _cached = null;
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Return the Python command or throw with a helpful message.
49
+ *
50
+ * @returns {string}
51
+ */
52
+ export function requirePython() {
53
+ const cmd = findPython();
54
+ if (!cmd) {
55
+ throw new Error(
56
+ 'Python 3 not found. Install Python 3.9+ and ensure "python3" or "python" is on PATH.'
57
+ );
58
+ }
59
+ return cmd;
60
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "5.0.0-beta.3",
3
+ "version": "5.0.0-beta.4",
4
4
  "description": "Full DevOps orchestration for Claude Code. Six specialized agents handle the complete development lifecycle \u2014 analysis, planning, execution, and deployment. Gaia-Ops scans your codebase to understand it and injects the right context into each sub-agent. Every command is classified by risk: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
5
5
  "author": {
6
6
  "name": "jaguilar87"
@@ -10,7 +10,9 @@ Handles:
10
10
  import json
11
11
  import logging
12
12
  import os
13
+ import shutil
13
14
  import subprocess
15
+ import sys
14
16
  from datetime import datetime
15
17
  from pathlib import Path
16
18
 
@@ -22,6 +24,20 @@ from .contracts_loader import build_context_update_reminder
22
24
  logger = logging.getLogger(__name__)
23
25
 
24
26
 
27
+ def _find_python() -> str:
28
+ """Return the Python 3 command name for this platform.
29
+
30
+ Tries ``python3`` first (Linux/macOS), then ``python`` (Windows).
31
+ Falls back to ``sys.executable`` (the current interpreter) as a
32
+ last resort -- this always works since hooks are already running
33
+ under Python.
34
+ """
35
+ for cmd in ("python3", "python"):
36
+ if shutil.which(cmd):
37
+ return cmd
38
+ return sys.executable
39
+
40
+
25
41
  def _prune_empty_values(data: dict) -> dict:
26
42
  """Drop keys with empty telemetry values while preserving False/0."""
27
43
  pruned = {}
@@ -288,7 +304,7 @@ def build_project_context(
288
304
  # Execute context_provider.py to get filtered context
289
305
  logger.info(f"Building context for {subagent_type}...")
290
306
  result = subprocess.run(
291
- ["python3", str(context_provider), subagent_type, prompt],
307
+ [_find_python(), str(context_provider), subagent_type, prompt],
292
308
  capture_output=True,
293
309
  text=True,
294
310
  timeout=15,
@@ -60,6 +60,8 @@ The test: for each rule or step you write, ask — if the agent saw enough real
60
60
 
61
61
  This is why the investigation skill doesn't say "INVESTIGATE FIRST. ALWAYS. NO EXCEPTIONS." It says: *"Every codebase is a record of accumulated decisions... The first 2-3 files you read define whether your solution fits or fights the project."* The agent understands the stakes. The behavior follows.
62
62
 
63
+ Every line in a skill competes for weight in the LLM's reasoning. A rule without context carries almost no weight — the model has no reason to prioritize it over competing signals. An explanation with consequences carries enough weight to influence decisions even under pressure. This is why conciseness matters: a verbose skill dilutes its own weight. Every line should earn its place by adding reasoning the model can use.
64
+
63
65
  ### Tone by type
64
66
 
65
67
  **Discipline** works best when the Iron Law is blunt and a reasoned paragraph follows explaining what breaks when you violate it. Command-execution's mental model ("When you reach for a pipe, you have not looked for the flag yet") does more work than a dozen capitalized warnings because it reframes the decision point itself.
@@ -35,6 +35,12 @@ _RUNTIME_DEFINITIONS: List[Tuple[str, str]] = [
35
35
  ("java", "--version"),
36
36
  ]
37
37
 
38
+ # On Windows, python3 may not exist -- fall back to "python" if it reports 3.x.
39
+ # Maps (fallback_binary, version_flag) -> canonical_name
40
+ _RUNTIME_FALLBACKS: Dict[str, List[Tuple[str, str]]] = {
41
+ "python3": [("python", "--version")],
42
+ }
43
+
38
44
  # Env file names to check for (presence ONLY -- never read contents)
39
45
  _ENV_FILE_NAMES: List[str] = [
40
46
  ".env",
@@ -182,14 +188,32 @@ class EnvironmentScanner(BaseScanner):
182
188
  """Detect installed language runtimes via --version commands.
183
189
 
184
190
  Uses shutil.which() to find binaries, then subprocess with 2s timeout
185
- to get version strings.
191
+ to get version strings. For runtimes with fallbacks (e.g. python3 ->
192
+ python on Windows), the fallback is tried when the primary is missing
193
+ and the result is reported under the canonical name.
186
194
  """
187
195
  runtimes: List[Dict[str, str]] = []
196
+ detected_canonical: set = set()
188
197
 
189
198
  for binary_name, version_flag in _RUNTIME_DEFINITIONS:
190
199
  try:
191
200
  binary_path = shutil.which(binary_name)
192
201
  if binary_path is None:
202
+ # Try fallbacks (e.g. python -> python3 on Windows)
203
+ fallbacks = _RUNTIME_FALLBACKS.get(binary_name, [])
204
+ for fb_binary, fb_flag in fallbacks:
205
+ fb_path = shutil.which(fb_binary)
206
+ if fb_path is None:
207
+ continue
208
+ version = self._get_version(fb_binary, fb_flag, warnings)
209
+ if version is not None and version.startswith("3."):
210
+ runtimes.append({
211
+ "name": binary_name, # canonical name
212
+ "version": version,
213
+ "path": fb_path,
214
+ })
215
+ detected_canonical.add(binary_name)
216
+ break
193
217
  continue
194
218
 
195
219
  version = self._get_version(binary_name, version_flag, warnings)
@@ -199,6 +223,7 @@ class EnvironmentScanner(BaseScanner):
199
223
  "version": version,
200
224
  "path": binary_path,
201
225
  })
226
+ detected_canonical.add(binary_name)
202
227
 
203
228
  except Exception as exc:
204
229
  warnings.append(f"Runtime detection failed for {binary_name}: {exc}")
@@ -159,23 +159,27 @@ def check_project_context(project_root: Path) -> CheckResult:
159
159
 
160
160
 
161
161
  def check_python() -> CheckResult:
162
- """Verify that python3 is available.
162
+ """Verify that python3 (or python on Windows) is available.
163
+
164
+ Tries ``python3`` first, then ``python`` for Windows compatibility.
163
165
 
164
166
  Returns:
165
167
  CheckResult with Python version.
166
168
  """
167
- try:
168
- result = subprocess.run(
169
- ["python3", "--version"],
170
- capture_output=True,
171
- text=True,
172
- timeout=5,
173
- )
174
- if result.returncode == 0:
175
- version = result.stdout.strip()
176
- return CheckResult(name="Python", ok=True, detail=version)
177
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
178
- pass
169
+ for cmd in ("python3", "python"):
170
+ try:
171
+ result = subprocess.run(
172
+ [cmd, "--version"],
173
+ capture_output=True,
174
+ text=True,
175
+ timeout=5,
176
+ )
177
+ if result.returncode == 0:
178
+ version = result.stdout.strip()
179
+ if version.startswith("Python 3."):
180
+ return CheckResult(name="Python", ok=True, detail=version)
181
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
182
+ pass
179
183
 
180
184
  return CheckResult(
181
185
  name="Python",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaia-security",
3
- "version": "5.0.0-beta.3",
3
+ "version": "5.0.0-beta.4",
4
4
  "description": "Keeps you in the loop only when it matters. Gaia Security analyzes every command and classifies it into risk tiers: read-only queries run freely, simulations and validations pass through, and state-changing operations (create, delete, apply, push) pause for your explicit approval before executing. Irreversible commands like dropping databases or deleting cloud infrastructure are permanently blocked.",
5
5
  "author": {
6
6
  "name": "jaguilar87"
@@ -10,7 +10,9 @@ Handles:
10
10
  import json
11
11
  import logging
12
12
  import os
13
+ import shutil
13
14
  import subprocess
15
+ import sys
14
16
  from datetime import datetime
15
17
  from pathlib import Path
16
18
 
@@ -22,6 +24,20 @@ from .contracts_loader import build_context_update_reminder
22
24
  logger = logging.getLogger(__name__)
23
25
 
24
26
 
27
+ def _find_python() -> str:
28
+ """Return the Python 3 command name for this platform.
29
+
30
+ Tries ``python3`` first (Linux/macOS), then ``python`` (Windows).
31
+ Falls back to ``sys.executable`` (the current interpreter) as a
32
+ last resort -- this always works since hooks are already running
33
+ under Python.
34
+ """
35
+ for cmd in ("python3", "python"):
36
+ if shutil.which(cmd):
37
+ return cmd
38
+ return sys.executable
39
+
40
+
25
41
  def _prune_empty_values(data: dict) -> dict:
26
42
  """Drop keys with empty telemetry values while preserving False/0."""
27
43
  pruned = {}
@@ -288,7 +304,7 @@ def build_project_context(
288
304
  # Execute context_provider.py to get filtered context
289
305
  logger.info(f"Building context for {subagent_type}...")
290
306
  result = subprocess.run(
291
- ["python3", str(context_provider), subagent_type, prompt],
307
+ [_find_python(), str(context_provider), subagent_type, prompt],
292
308
  capture_output=True,
293
309
  text=True,
294
310
  timeout=15,
@@ -10,7 +10,9 @@ Handles:
10
10
  import json
11
11
  import logging
12
12
  import os
13
+ import shutil
13
14
  import subprocess
15
+ import sys
14
16
  from datetime import datetime
15
17
  from pathlib import Path
16
18
 
@@ -22,6 +24,20 @@ from .contracts_loader import build_context_update_reminder
22
24
  logger = logging.getLogger(__name__)
23
25
 
24
26
 
27
+ def _find_python() -> str:
28
+ """Return the Python 3 command name for this platform.
29
+
30
+ Tries ``python3`` first (Linux/macOS), then ``python`` (Windows).
31
+ Falls back to ``sys.executable`` (the current interpreter) as a
32
+ last resort -- this always works since hooks are already running
33
+ under Python.
34
+ """
35
+ for cmd in ("python3", "python"):
36
+ if shutil.which(cmd):
37
+ return cmd
38
+ return sys.executable
39
+
40
+
25
41
  def _prune_empty_values(data: dict) -> dict:
26
42
  """Drop keys with empty telemetry values while preserving False/0."""
27
43
  pruned = {}
@@ -288,7 +304,7 @@ def build_project_context(
288
304
  # Execute context_provider.py to get filtered context
289
305
  logger.info(f"Building context for {subagent_type}...")
290
306
  result = subprocess.run(
291
- ["python3", str(context_provider), subagent_type, prompt],
307
+ [_find_python(), str(context_provider), subagent_type, prompt],
292
308
  capture_output=True,
293
309
  text=True,
294
310
  timeout=15,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia-ops",
3
- "version": "5.0.0-beta.3",
3
+ "version": "5.0.0-beta.4",
4
4
  "description": "Multi-agent orchestration system for Claude Code - DevOps automation toolkit",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -60,6 +60,8 @@ The test: for each rule or step you write, ask — if the agent saw enough real
60
60
 
61
61
  This is why the investigation skill doesn't say "INVESTIGATE FIRST. ALWAYS. NO EXCEPTIONS." It says: *"Every codebase is a record of accumulated decisions... The first 2-3 files you read define whether your solution fits or fights the project."* The agent understands the stakes. The behavior follows.
62
62
 
63
+ Every line in a skill competes for weight in the LLM's reasoning. A rule without context carries almost no weight — the model has no reason to prioritize it over competing signals. An explanation with consequences carries enough weight to influence decisions even under pressure. This is why conciseness matters: a verbose skill dilutes its own weight. Every line should earn its place by adding reasoning the model can use.
64
+
63
65
  ### Tone by type
64
66
 
65
67
  **Discipline** works best when the Iron Law is blunt and a reasoned paragraph follows explaining what breaks when you violate it. Command-execution's mental model ("When you reach for a pipe, you have not looked for the flag yet") does more work than a dozen capitalized warnings because it reframes the decision point itself.
@@ -35,6 +35,12 @@ _RUNTIME_DEFINITIONS: List[Tuple[str, str]] = [
35
35
  ("java", "--version"),
36
36
  ]
37
37
 
38
+ # On Windows, python3 may not exist -- fall back to "python" if it reports 3.x.
39
+ # Maps (fallback_binary, version_flag) -> canonical_name
40
+ _RUNTIME_FALLBACKS: Dict[str, List[Tuple[str, str]]] = {
41
+ "python3": [("python", "--version")],
42
+ }
43
+
38
44
  # Env file names to check for (presence ONLY -- never read contents)
39
45
  _ENV_FILE_NAMES: List[str] = [
40
46
  ".env",
@@ -182,14 +188,32 @@ class EnvironmentScanner(BaseScanner):
182
188
  """Detect installed language runtimes via --version commands.
183
189
 
184
190
  Uses shutil.which() to find binaries, then subprocess with 2s timeout
185
- to get version strings.
191
+ to get version strings. For runtimes with fallbacks (e.g. python3 ->
192
+ python on Windows), the fallback is tried when the primary is missing
193
+ and the result is reported under the canonical name.
186
194
  """
187
195
  runtimes: List[Dict[str, str]] = []
196
+ detected_canonical: set = set()
188
197
 
189
198
  for binary_name, version_flag in _RUNTIME_DEFINITIONS:
190
199
  try:
191
200
  binary_path = shutil.which(binary_name)
192
201
  if binary_path is None:
202
+ # Try fallbacks (e.g. python -> python3 on Windows)
203
+ fallbacks = _RUNTIME_FALLBACKS.get(binary_name, [])
204
+ for fb_binary, fb_flag in fallbacks:
205
+ fb_path = shutil.which(fb_binary)
206
+ if fb_path is None:
207
+ continue
208
+ version = self._get_version(fb_binary, fb_flag, warnings)
209
+ if version is not None and version.startswith("3."):
210
+ runtimes.append({
211
+ "name": binary_name, # canonical name
212
+ "version": version,
213
+ "path": fb_path,
214
+ })
215
+ detected_canonical.add(binary_name)
216
+ break
193
217
  continue
194
218
 
195
219
  version = self._get_version(binary_name, version_flag, warnings)
@@ -199,6 +223,7 @@ class EnvironmentScanner(BaseScanner):
199
223
  "version": version,
200
224
  "path": binary_path,
201
225
  })
226
+ detected_canonical.add(binary_name)
202
227
 
203
228
  except Exception as exc:
204
229
  warnings.append(f"Runtime detection failed for {binary_name}: {exc}")
@@ -159,23 +159,27 @@ def check_project_context(project_root: Path) -> CheckResult:
159
159
 
160
160
 
161
161
  def check_python() -> CheckResult:
162
- """Verify that python3 is available.
162
+ """Verify that python3 (or python on Windows) is available.
163
+
164
+ Tries ``python3`` first, then ``python`` for Windows compatibility.
163
165
 
164
166
  Returns:
165
167
  CheckResult with Python version.
166
168
  """
167
- try:
168
- result = subprocess.run(
169
- ["python3", "--version"],
170
- capture_output=True,
171
- text=True,
172
- timeout=5,
173
- )
174
- if result.returncode == 0:
175
- version = result.stdout.strip()
176
- return CheckResult(name="Python", ok=True, detail=version)
177
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
178
- pass
169
+ for cmd in ("python3", "python"):
170
+ try:
171
+ result = subprocess.run(
172
+ [cmd, "--version"],
173
+ capture_output=True,
174
+ text=True,
175
+ timeout=5,
176
+ )
177
+ if result.returncode == 0:
178
+ version = result.stdout.strip()
179
+ if version.startswith("Python 3."):
180
+ return CheckResult(name="Python", ok=True, detail=version)
181
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
182
+ pass
179
183
 
180
184
  return CheckResult(
181
185
  name="Python",