@jaguilar87/gaia-ops 5.0.0-beta.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bin/gaia-doctor.js +14 -2
- package/bin/gaia-review.js +3 -1
- package/bin/gaia-scan +40 -10
- package/bin/gaia-skills-diagnose.js +4 -2
- package/bin/gaia-update.js +34 -18
- package/bin/pre-publish-validate.js +5 -2
- package/bin/python-detect.js +60 -0
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +17 -1
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +5 -1
- package/dist/gaia-ops/skills/skill-creation/SKILL.md +2 -0
- package/dist/gaia-ops/tools/scan/scanners/environment.py +26 -1
- package/dist/gaia-ops/tools/scan/setup.py +30 -4
- package/dist/gaia-ops/tools/scan/verify.py +17 -13
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/modules/context/context_injector.py +17 -1
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +5 -1
- package/hooks/modules/context/context_injector.py +17 -1
- package/hooks/modules/security/mutative_verbs.py +5 -1
- package/package.json +1 -1
- package/skills/skill-creation/SKILL.md +2 -0
- package/tools/scan/scanners/environment.py +26 -1
- package/tools/scan/setup.py +30 -4
- package/tools/scan/verify.py +17 -13
|
@@ -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.
|
|
11
|
+
"version": "5.0.0-beta.4",
|
|
12
12
|
"source": "./dist/gaia-security"
|
|
13
13
|
}
|
|
14
14
|
]
|
package/bin/gaia-doctor.js
CHANGED
|
@@ -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(
|
|
210
|
+
const { stdout } = await execAsync(`${pyCmd} --version`);
|
|
205
211
|
const version = stdout.trim();
|
|
206
212
|
const match = version.match(/(\d+)\.(\d+)/);
|
|
207
213
|
|
|
@@ -341,12 +347,18 @@ async function autoFix() {
|
|
|
341
347
|
if (existsSync(packagePath)) {
|
|
342
348
|
const relPath = relative(claudeDir, packagePath);
|
|
343
349
|
const names = ['agents', 'tools', 'hooks', 'commands', 'templates', 'config', 'speckit', 'skills'];
|
|
350
|
+
// Use junctions on Windows (no admin required), regular symlinks elsewhere
|
|
351
|
+
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
|
|
344
352
|
|
|
345
353
|
for (const name of names) {
|
|
346
354
|
const link = join(claudeDir, name);
|
|
347
355
|
if (!existsSync(link)) {
|
|
348
356
|
try {
|
|
349
|
-
|
|
357
|
+
// Junctions on Windows require absolute targets; symlinks on Unix use relative
|
|
358
|
+
const target = process.platform === 'win32'
|
|
359
|
+
? join(packagePath, name)
|
|
360
|
+
: join(relPath, name);
|
|
361
|
+
await fs.symlink(target, link, linkType);
|
|
350
362
|
console.log(chalk.green(` Fixed: .claude/${name} symlink`));
|
|
351
363
|
fixed++;
|
|
352
364
|
} catch {
|
package/bin/gaia-review.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -1,14 +1,44 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
# Shell wrapper for gaia-scan CLI.
|
|
3
|
-
# Invokes the Python scanner with all forwarded arguments.
|
|
4
|
-
# Used as the npm bin entry point for `npx gaia-scan` / `npx gaia scan`.
|
|
1
|
+
#!/usr/bin/env node
|
|
5
2
|
|
|
6
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform wrapper for gaia-scan CLI.
|
|
5
|
+
* Invokes the Python scanner with all forwarded arguments.
|
|
6
|
+
* Used as the npm bin entry point for `npx gaia-scan`.
|
|
7
|
+
*
|
|
8
|
+
* Replaces the previous bash wrapper so that it works on Windows
|
|
9
|
+
* (where /usr/bin/env bash does not exist).
|
|
10
|
+
*/
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
import { execFileSync } from 'child_process';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { dirname, join } from 'path';
|
|
15
|
+
import { requirePython } from './python-detect.js';
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = dirname(__filename);
|
|
19
|
+
const pluginRoot = join(__dirname, '..');
|
|
20
|
+
const scanScript = join(__dirname, 'gaia-scan.py');
|
|
13
21
|
|
|
14
|
-
|
|
22
|
+
// Forward all CLI arguments after the script name
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
|
|
25
|
+
// Set PYTHONPATH so `tools.scan` resolves inside the package
|
|
26
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
27
|
+
const pythonPath = process.env.PYTHONPATH
|
|
28
|
+
? `${pluginRoot}${sep}${process.env.PYTHONPATH}`
|
|
29
|
+
: pluginRoot;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const pyCmd = requirePython();
|
|
33
|
+
execFileSync(pyCmd, [scanScript, ...args], {
|
|
34
|
+
stdio: 'inherit',
|
|
35
|
+
env: { ...process.env, PYTHONPATH: pythonPath },
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.message && error.message.includes('Python 3 not found')) {
|
|
39
|
+
console.error(error.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
// execFileSync throws on non-zero exit; propagate the exit code
|
|
43
|
+
process.exit(error.status || 1);
|
|
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
|
|
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:
|
|
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" });
|
package/bin/gaia-update.js
CHANGED
|
@@ -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);
|
|
@@ -44,6 +45,9 @@ const __dirname = dirname(__filename);
|
|
|
44
45
|
const CWD = process.env.INIT_CWD || process.cwd();
|
|
45
46
|
const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
46
47
|
|
|
48
|
+
// Use junctions on Windows (no admin required), regular symlinks elsewhere
|
|
49
|
+
const LINK_TYPE = process.platform === 'win32' ? 'junction' : 'dir';
|
|
50
|
+
|
|
47
51
|
// ============================================================================
|
|
48
52
|
// Version Detection
|
|
49
53
|
// ============================================================================
|
|
@@ -123,8 +127,9 @@ async function updateLocalPermissions() {
|
|
|
123
127
|
let gaiaPerms;
|
|
124
128
|
try {
|
|
125
129
|
const setupPath = join(__dirname, '..', 'hooks', 'modules', 'core', 'plugin_setup.py');
|
|
130
|
+
const pythonCmd = findPython() || 'python3';
|
|
126
131
|
const { stdout } = await execAsync(
|
|
127
|
-
|
|
132
|
+
`${pythonCmd} -c "
|
|
128
133
|
import ast, json, re
|
|
129
134
|
|
|
130
135
|
source = open('${setupPath.replace(/'/g, "\\'")}').read()
|
|
@@ -376,11 +381,14 @@ async function updateSymlinks() {
|
|
|
376
381
|
|
|
377
382
|
for (const name of symlinks) {
|
|
378
383
|
const link = join(claudeDir, name);
|
|
379
|
-
|
|
384
|
+
// Junctions on Windows require absolute targets; symlinks on Unix use relative
|
|
385
|
+
const target = process.platform === 'win32'
|
|
386
|
+
? join(packagePath, name)
|
|
387
|
+
: join(relativePath, name);
|
|
380
388
|
|
|
381
389
|
if (!existsSync(link)) {
|
|
382
390
|
try {
|
|
383
|
-
await fs.symlink(target, link);
|
|
391
|
+
await fs.symlink(target, link, LINK_TYPE);
|
|
384
392
|
fixed++;
|
|
385
393
|
} catch { /* skip */ }
|
|
386
394
|
} else {
|
|
@@ -391,7 +399,7 @@ async function updateSymlinks() {
|
|
|
391
399
|
// Broken symlink — remove and recreate
|
|
392
400
|
try {
|
|
393
401
|
await fs.unlink(link);
|
|
394
|
-
await fs.symlink(target, link);
|
|
402
|
+
await fs.symlink(target, link, LINK_TYPE);
|
|
395
403
|
fixed++;
|
|
396
404
|
} catch { /* skip */ }
|
|
397
405
|
}
|
|
@@ -402,7 +410,12 @@ async function updateSymlinks() {
|
|
|
402
410
|
const changelogLink = join(claudeDir, 'CHANGELOG.md');
|
|
403
411
|
if (!existsSync(changelogLink)) {
|
|
404
412
|
try {
|
|
405
|
-
|
|
413
|
+
if (process.platform === 'win32') {
|
|
414
|
+
// Junctions only work for directories; copy the file on Windows
|
|
415
|
+
await fs.copyFile(join(packagePath, 'CHANGELOG.md'), changelogLink);
|
|
416
|
+
} else {
|
|
417
|
+
await fs.symlink(join(relativePath, 'CHANGELOG.md'), changelogLink);
|
|
418
|
+
}
|
|
406
419
|
fixed++;
|
|
407
420
|
} catch { /* skip */ }
|
|
408
421
|
}
|
|
@@ -442,13 +455,16 @@ async function runVerification() {
|
|
|
442
455
|
}
|
|
443
456
|
}
|
|
444
457
|
|
|
445
|
-
// 2. Python available
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
+
}
|
|
452
468
|
}
|
|
453
469
|
|
|
454
470
|
// 3. project-context.json exists and is valid
|
|
@@ -529,12 +545,12 @@ async function runFreshInstall() {
|
|
|
529
545
|
|
|
530
546
|
console.log(chalk.cyan(`\n gaia-ops ${chalk.green(current)} — fresh install\n`));
|
|
531
547
|
|
|
532
|
-
// 1. Check Python 3 is available
|
|
548
|
+
// 1. Check Python 3 is available (try python3, then python)
|
|
533
549
|
const spinner = ora('Checking Python 3...').start();
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
spinner.succeed(
|
|
537
|
-
}
|
|
550
|
+
const pyCmd = findPython();
|
|
551
|
+
if (pyCmd) {
|
|
552
|
+
spinner.succeed(`Python 3 found (${pyCmd})`);
|
|
553
|
+
} else {
|
|
538
554
|
spinner.warn('Python 3 not found — skipping project setup');
|
|
539
555
|
console.log(chalk.gray(' Install Python 3.9+ and run: npx gaia-scan\n'));
|
|
540
556
|
return;
|
|
@@ -544,7 +560,7 @@ async function runFreshInstall() {
|
|
|
544
560
|
const scanSpinner = ora('Running gaia-scan...').start();
|
|
545
561
|
try {
|
|
546
562
|
const { stdout, stderr } = await execAsync(
|
|
547
|
-
|
|
563
|
+
`${pyCmd} "${scanScript}" --npm-postinstall --root "${CWD}"`,
|
|
548
564
|
{ timeout: 60000 }
|
|
549
565
|
);
|
|
550
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(
|
|
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
|
+
"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
|
-
[
|
|
307
|
+
[_find_python(), str(context_provider), subagent_type, prompt],
|
|
292
308
|
capture_output=True,
|
|
293
309
|
text=True,
|
|
294
310
|
timeout=15,
|
|
@@ -86,7 +86,11 @@ MUTATIVE_VERBS: FrozenSet[str] = frozenset({
|
|
|
86
86
|
"update", "patch", "set", "modify", "edit", "configure",
|
|
87
87
|
"replace", "overwrite", "write",
|
|
88
88
|
# Deployment / packaging
|
|
89
|
-
|
|
89
|
+
# NOTE: "release" removed -- it is a CLI subcommand group noun in `gh release`,
|
|
90
|
+
# `glab release`, etc. The actual mutative actions (create, delete, edit, upload)
|
|
91
|
+
# are already in MUTATIVE_VERBS. Keeping "release" here causes false positives on
|
|
92
|
+
# `gh release view` and any command with "release" as an argument string.
|
|
93
|
+
"deploy", "install", "upgrade", "downgrade", "publish", "promote",
|
|
90
94
|
# Scaling
|
|
91
95
|
"scale", "resize", "autoscale",
|
|
92
96
|
# Lifecycle
|
|
@@ -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}")
|
|
@@ -19,6 +19,7 @@ Functions:
|
|
|
19
19
|
import json
|
|
20
20
|
import logging
|
|
21
21
|
import os
|
|
22
|
+
import platform
|
|
22
23
|
import shutil
|
|
23
24
|
import subprocess
|
|
24
25
|
from datetime import datetime, timezone
|
|
@@ -27,6 +28,23 @@ from typing import Any, Dict, List, Optional
|
|
|
27
28
|
|
|
28
29
|
logger = logging.getLogger(__name__)
|
|
29
30
|
|
|
31
|
+
# Windows detection: junctions don't require admin privileges
|
|
32
|
+
_IS_WINDOWS = platform.system() == "Windows"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _create_dir_link(target: str, link: str) -> None:
|
|
36
|
+
"""Create a directory link: junction on Windows, symlink on Unix.
|
|
37
|
+
|
|
38
|
+
Windows junctions don't require admin/developer-mode privileges,
|
|
39
|
+
unlike directory symlinks. The target must be an absolute path
|
|
40
|
+
on Windows (junctions don't support relative targets).
|
|
41
|
+
"""
|
|
42
|
+
if _IS_WINDOWS:
|
|
43
|
+
import _winapi # stdlib on Windows, unavailable elsewhere
|
|
44
|
+
_winapi.CreateJunction(target, link)
|
|
45
|
+
else:
|
|
46
|
+
os.symlink(target, link)
|
|
47
|
+
|
|
30
48
|
|
|
31
49
|
def _find_package_root() -> Path:
|
|
32
50
|
"""Find the gaia-ops plugin root directory.
|
|
@@ -191,23 +209,31 @@ def create_claude_directory(project_root: Path) -> List[str]:
|
|
|
191
209
|
|
|
192
210
|
for name in symlink_names:
|
|
193
211
|
link_path = claude_dir / name
|
|
194
|
-
|
|
212
|
+
# Junctions on Windows require absolute targets; symlinks on Unix use relative
|
|
213
|
+
if _IS_WINDOWS:
|
|
214
|
+
target = str(package_path / name)
|
|
215
|
+
else:
|
|
216
|
+
target = os.path.join(rel_path, name)
|
|
195
217
|
|
|
196
218
|
if link_path.exists() or link_path.is_symlink():
|
|
197
219
|
link_path.unlink()
|
|
198
220
|
|
|
199
221
|
try:
|
|
200
|
-
|
|
222
|
+
_create_dir_link(target, str(link_path))
|
|
201
223
|
created.append(name)
|
|
202
224
|
except OSError as exc:
|
|
203
225
|
logger.warning("Failed to create symlink %s: %s", name, exc)
|
|
204
226
|
|
|
205
|
-
# CHANGELOG.md symlink
|
|
227
|
+
# CHANGELOG.md symlink (file, not directory — junctions only work for dirs)
|
|
206
228
|
changelog_link = claude_dir / "CHANGELOG.md"
|
|
207
229
|
if changelog_link.exists() or changelog_link.is_symlink():
|
|
208
230
|
changelog_link.unlink()
|
|
209
231
|
try:
|
|
210
|
-
|
|
232
|
+
if _IS_WINDOWS:
|
|
233
|
+
# File symlinks need admin on Windows; copy instead
|
|
234
|
+
shutil.copy2(str(package_path / "CHANGELOG.md"), str(changelog_link))
|
|
235
|
+
else:
|
|
236
|
+
os.symlink(os.path.join(rel_path, "CHANGELOG.md"), str(changelog_link))
|
|
211
237
|
created.append("CHANGELOG.md")
|
|
212
238
|
except OSError as exc:
|
|
213
239
|
logger.warning("Failed to create CHANGELOG.md symlink: %s", 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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
"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
|
-
[
|
|
307
|
+
[_find_python(), str(context_provider), subagent_type, prompt],
|
|
292
308
|
capture_output=True,
|
|
293
309
|
text=True,
|
|
294
310
|
timeout=15,
|
|
@@ -86,7 +86,11 @@ MUTATIVE_VERBS: FrozenSet[str] = frozenset({
|
|
|
86
86
|
"update", "patch", "set", "modify", "edit", "configure",
|
|
87
87
|
"replace", "overwrite", "write",
|
|
88
88
|
# Deployment / packaging
|
|
89
|
-
|
|
89
|
+
# NOTE: "release" removed -- it is a CLI subcommand group noun in `gh release`,
|
|
90
|
+
# `glab release`, etc. The actual mutative actions (create, delete, edit, upload)
|
|
91
|
+
# are already in MUTATIVE_VERBS. Keeping "release" here causes false positives on
|
|
92
|
+
# `gh release view` and any command with "release" as an argument string.
|
|
93
|
+
"deploy", "install", "upgrade", "downgrade", "publish", "promote",
|
|
90
94
|
# Scaling
|
|
91
95
|
"scale", "resize", "autoscale",
|
|
92
96
|
# Lifecycle
|
|
@@ -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
|
-
[
|
|
307
|
+
[_find_python(), str(context_provider), subagent_type, prompt],
|
|
292
308
|
capture_output=True,
|
|
293
309
|
text=True,
|
|
294
310
|
timeout=15,
|
|
@@ -86,7 +86,11 @@ MUTATIVE_VERBS: FrozenSet[str] = frozenset({
|
|
|
86
86
|
"update", "patch", "set", "modify", "edit", "configure",
|
|
87
87
|
"replace", "overwrite", "write",
|
|
88
88
|
# Deployment / packaging
|
|
89
|
-
|
|
89
|
+
# NOTE: "release" removed -- it is a CLI subcommand group noun in `gh release`,
|
|
90
|
+
# `glab release`, etc. The actual mutative actions (create, delete, edit, upload)
|
|
91
|
+
# are already in MUTATIVE_VERBS. Keeping "release" here causes false positives on
|
|
92
|
+
# `gh release view` and any command with "release" as an argument string.
|
|
93
|
+
"deploy", "install", "upgrade", "downgrade", "publish", "promote",
|
|
90
94
|
# Scaling
|
|
91
95
|
"scale", "resize", "autoscale",
|
|
92
96
|
# Lifecycle
|
package/package.json
CHANGED
|
@@ -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}")
|
package/tools/scan/setup.py
CHANGED
|
@@ -19,6 +19,7 @@ Functions:
|
|
|
19
19
|
import json
|
|
20
20
|
import logging
|
|
21
21
|
import os
|
|
22
|
+
import platform
|
|
22
23
|
import shutil
|
|
23
24
|
import subprocess
|
|
24
25
|
from datetime import datetime, timezone
|
|
@@ -27,6 +28,23 @@ from typing import Any, Dict, List, Optional
|
|
|
27
28
|
|
|
28
29
|
logger = logging.getLogger(__name__)
|
|
29
30
|
|
|
31
|
+
# Windows detection: junctions don't require admin privileges
|
|
32
|
+
_IS_WINDOWS = platform.system() == "Windows"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _create_dir_link(target: str, link: str) -> None:
|
|
36
|
+
"""Create a directory link: junction on Windows, symlink on Unix.
|
|
37
|
+
|
|
38
|
+
Windows junctions don't require admin/developer-mode privileges,
|
|
39
|
+
unlike directory symlinks. The target must be an absolute path
|
|
40
|
+
on Windows (junctions don't support relative targets).
|
|
41
|
+
"""
|
|
42
|
+
if _IS_WINDOWS:
|
|
43
|
+
import _winapi # stdlib on Windows, unavailable elsewhere
|
|
44
|
+
_winapi.CreateJunction(target, link)
|
|
45
|
+
else:
|
|
46
|
+
os.symlink(target, link)
|
|
47
|
+
|
|
30
48
|
|
|
31
49
|
def _find_package_root() -> Path:
|
|
32
50
|
"""Find the gaia-ops plugin root directory.
|
|
@@ -191,23 +209,31 @@ def create_claude_directory(project_root: Path) -> List[str]:
|
|
|
191
209
|
|
|
192
210
|
for name in symlink_names:
|
|
193
211
|
link_path = claude_dir / name
|
|
194
|
-
|
|
212
|
+
# Junctions on Windows require absolute targets; symlinks on Unix use relative
|
|
213
|
+
if _IS_WINDOWS:
|
|
214
|
+
target = str(package_path / name)
|
|
215
|
+
else:
|
|
216
|
+
target = os.path.join(rel_path, name)
|
|
195
217
|
|
|
196
218
|
if link_path.exists() or link_path.is_symlink():
|
|
197
219
|
link_path.unlink()
|
|
198
220
|
|
|
199
221
|
try:
|
|
200
|
-
|
|
222
|
+
_create_dir_link(target, str(link_path))
|
|
201
223
|
created.append(name)
|
|
202
224
|
except OSError as exc:
|
|
203
225
|
logger.warning("Failed to create symlink %s: %s", name, exc)
|
|
204
226
|
|
|
205
|
-
# CHANGELOG.md symlink
|
|
227
|
+
# CHANGELOG.md symlink (file, not directory — junctions only work for dirs)
|
|
206
228
|
changelog_link = claude_dir / "CHANGELOG.md"
|
|
207
229
|
if changelog_link.exists() or changelog_link.is_symlink():
|
|
208
230
|
changelog_link.unlink()
|
|
209
231
|
try:
|
|
210
|
-
|
|
232
|
+
if _IS_WINDOWS:
|
|
233
|
+
# File symlinks need admin on Windows; copy instead
|
|
234
|
+
shutil.copy2(str(package_path / "CHANGELOG.md"), str(changelog_link))
|
|
235
|
+
else:
|
|
236
|
+
os.symlink(os.path.join(rel_path, "CHANGELOG.md"), str(changelog_link))
|
|
211
237
|
created.append("CHANGELOG.md")
|
|
212
238
|
except OSError as exc:
|
|
213
239
|
logger.warning("Failed to create CHANGELOG.md symlink: %s", exc)
|
package/tools/scan/verify.py
CHANGED
|
@@ -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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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",
|