@microsoft/m365-copilot-eval 1.0.1-preview.1

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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/TERMS.txt +65 -0
  4. package/package.json +82 -0
  5. package/src/clients/cli/auth/__init__.py +1 -0
  6. package/src/clients/cli/auth/auth_handler.py +262 -0
  7. package/src/clients/cli/custom_evaluators/CitationsEvaluator.py +136 -0
  8. package/src/clients/cli/custom_evaluators/ConcisenessNonLLMEvaluator.py +18 -0
  9. package/src/clients/cli/custom_evaluators/ExactMatchEvaluator.py +25 -0
  10. package/src/clients/cli/custom_evaluators/PII/PII.py +45 -0
  11. package/src/clients/cli/custom_evaluators/PartialMatchEvaluator.py +39 -0
  12. package/src/clients/cli/custom_evaluators/__init__.py +1 -0
  13. package/src/clients/cli/demo_usage.py +83 -0
  14. package/src/clients/cli/generate_report.py +251 -0
  15. package/src/clients/cli/main.py +766 -0
  16. package/src/clients/cli/readme.md +301 -0
  17. package/src/clients/cli/requirements.txt +10 -0
  18. package/src/clients/cli/response_extractor.py +589 -0
  19. package/src/clients/cli/samples/PartnerSuccess.json +122 -0
  20. package/src/clients/cli/samples/example_prompts.json +14 -0
  21. package/src/clients/cli/samples/example_prompts_alt.json +12 -0
  22. package/src/clients/cli/samples/prompts_ambiguity.json +22 -0
  23. package/src/clients/cli/samples/prompts_rag_grounding.json +22 -0
  24. package/src/clients/cli/samples/prompts_security_injection.json +22 -0
  25. package/src/clients/cli/samples/prompts_tool_use_negatives.json +22 -0
  26. package/src/clients/cli/samples/psaSample.json +18 -0
  27. package/src/clients/cli/samples/starter.json +10 -0
  28. package/src/clients/node-js/bin/runevals.js +505 -0
  29. package/src/clients/node-js/config/default.js +25 -0
  30. package/src/clients/node-js/lib/cache-utils.js +119 -0
  31. package/src/clients/node-js/lib/expiry-check.js +164 -0
  32. package/src/clients/node-js/lib/index.js +25 -0
  33. package/src/clients/node-js/lib/python-runtime.js +253 -0
  34. package/src/clients/node-js/lib/venv-manager.js +242 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Package expiry check utility
3
+ * Checks if the package has expired based on publish date + 60 days
4
+ */
5
+
6
+ import { fileURLToPath } from 'url';
7
+ import path from 'path';
8
+ import fs from 'fs';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ const EXPIRY_DAYS = 60;
14
+ const EXPIRY_MESSAGE =
15
+ 'This version of the M365 Evals CLI has stopped working and must be updated';
16
+
17
+ /**
18
+ * Get the package publish date from package.json
19
+ * @returns {Date|null} The publish date or null if not found
20
+ */
21
+ function getPackagePublishDate() {
22
+ try {
23
+ const packageJsonPath = path.join(
24
+ __dirname,
25
+ '..',
26
+ '..',
27
+ '..',
28
+ '..',
29
+ 'package.json'
30
+ );
31
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
32
+
33
+ // Check if publishDate is set in package.json
34
+ if (packageJson.publishDate) {
35
+ return new Date(packageJson.publishDate);
36
+ }
37
+
38
+ // Fall back to package.json modification time as an approximation
39
+ const stats = fs.statSync(packageJsonPath);
40
+ return stats.mtime;
41
+ } catch (error) {
42
+ console.warn('Warning: Could not determine package publish date');
43
+ return error;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Calculate the expiry date (publish date + 60 days)
49
+ * @param {Date} publishDate - The package publish date
50
+ * @returns {Date} The expiry date
51
+ */
52
+ function calculateExpiryDate(publishDate) {
53
+ const expiryDate = new Date(publishDate);
54
+ expiryDate.setDate(expiryDate.getDate() + EXPIRY_DAYS);
55
+ return expiryDate;
56
+ }
57
+
58
+ /**
59
+ * Check if the package has expired
60
+ * @returns {boolean} True if expired, false otherwise
61
+ */
62
+ export function isPackageExpired() {
63
+ const publishDate = getPackagePublishDate();
64
+
65
+ if (!publishDate) {
66
+ // If we can't determine the publish date, don't block execution
67
+ return false;
68
+ }
69
+
70
+ const expiryDate = calculateExpiryDate(publishDate);
71
+ const now = new Date();
72
+
73
+ return now > expiryDate;
74
+ }
75
+
76
+ /**
77
+ * Get information about the package expiry status
78
+ * @returns {Object} Expiry information including dates and days remaining
79
+ */
80
+ export function getExpiryInfo() {
81
+ const publishDate = getPackagePublishDate();
82
+
83
+ if (!publishDate) {
84
+ return {
85
+ expired: false,
86
+ publishDate: null,
87
+ expiryDate: null,
88
+ daysRemaining: null,
89
+ };
90
+ }
91
+
92
+ const expiryDate = calculateExpiryDate(publishDate);
93
+ const now = new Date();
94
+ const daysRemaining = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24));
95
+
96
+ return {
97
+ expired: now > expiryDate,
98
+ publishDate: publishDate.toISOString().split('T')[0],
99
+ expiryDate: expiryDate.toISOString().split('T')[0],
100
+ daysRemaining: daysRemaining > 0 ? daysRemaining : 0,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Calculate expiry information for a given publish date
106
+ * @param {string|Date|null} publishDate - The publish date
107
+ * @returns {Object} Expiry information
108
+ */
109
+ export function calculateExpiryInfo(publishDate) {
110
+ if (!publishDate) {
111
+ return {
112
+ expired: false,
113
+ publishDate: null,
114
+ expiryDate: null,
115
+ daysRemaining: null,
116
+ };
117
+ }
118
+
119
+ const pubDate =
120
+ typeof publishDate === 'string' ? new Date(publishDate) : publishDate;
121
+ const expiryDate = new Date(pubDate);
122
+ expiryDate.setDate(expiryDate.getDate() + EXPIRY_DAYS);
123
+
124
+ const now = new Date();
125
+ const daysRemaining = Math.ceil((expiryDate - now) / (1000 * 60 * 60 * 24));
126
+
127
+ return {
128
+ expired: now >= expiryDate,
129
+ publishDate: pubDate.toISOString().split('T')[0],
130
+ expiryDate: expiryDate.toISOString().split('T')[0],
131
+ daysRemaining: daysRemaining > 0 ? daysRemaining : 0,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Check package expiry based on expiry info and exit or warn accordingly
137
+ * @param {Object} info - Expiry information from calculateExpiryInfo()
138
+ */
139
+ export function handleExpiryCheck(info) {
140
+ // If expired, show error and exit
141
+ if (info.expired) {
142
+ console.error('\n❌ ' + EXPIRY_MESSAGE);
143
+ process.exit(1);
144
+ }
145
+
146
+ // If close to expiry (within 7 days), show warning
147
+ if (info.daysRemaining !== null && info.daysRemaining <= 7) {
148
+ console.warn(
149
+ '\n⚠️ This version of the M365 Evals CLI will stop working soon and should be updated.'
150
+ );
151
+ console.warn(` Days remaining: ${info.daysRemaining}`);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Check package expiry and exit if expired, or warn if close to expiry
157
+ * This is the main function that should be called at startup
158
+ * Displays appropriate message and exits with code 1 if expired
159
+ */
160
+ export function checkPackageExpiry() {
161
+ const publishDate = getPackagePublishDate();
162
+ const info = calculateExpiryInfo(publishDate);
163
+ handleExpiryCheck(info);
164
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @microsoft/m365-copilot-eval
3
+ *
4
+ * Zero-config Node.js wrapper for M365 Copilot Agent Evaluations
5
+ * using Python Build Standalone and Azure AI Evaluation SDK
6
+ */
7
+
8
+ export {
9
+ ensurePythonRuntime,
10
+ getPythonExecutable,
11
+ getCacheDir,
12
+ getPlatformKey,
13
+ } from './python-runtime.js';
14
+
15
+ export { ensureVenv, executePythonCli, getVenvDir } from './venv-manager.js';
16
+
17
+ export { getCacheStats, clearCache, formatBytes } from './cache-utils.js';
18
+
19
+ export {
20
+ checkPackageExpiry,
21
+ calculateExpiryInfo,
22
+ handleExpiryCheck,
23
+ isPackageExpired,
24
+ getExpiryInfo,
25
+ } from './expiry-check.js';
@@ -0,0 +1,253 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import fs from 'fs/promises';
4
+ import crypto from 'crypto';
5
+ import { pipeline } from 'stream/promises';
6
+ import { createWriteStream } from 'fs';
7
+ import fetch from 'node-fetch';
8
+
9
+ /**
10
+ * Python Build Standalone (PBS) configuration
11
+ * Using Python 3.13.10 builds from https://github.com/indygreg/python-build-standalone
12
+ */
13
+
14
+ // PBS release version (latest release with Python 3.13.10)
15
+ const PBS_VERSION = '20251202';
16
+ const PYTHON_VERSION = '3.13.10';
17
+
18
+ // Base URL for PBS releases
19
+ const PBS_BASE_URL = `https://github.com/indygreg/python-build-standalone/releases/download/${PBS_VERSION}`;
20
+
21
+ /**
22
+ * Platform-specific PBS distribution mapping
23
+ * Format: cpython-{version}+{build}-{arch}-{vendor}-{os}-{profile}.tar.{compression}
24
+ */
25
+ const PBS_DISTRIBUTIONS = {
26
+ 'darwin-x64': {
27
+ filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-x86_64-apple-darwin-install_only.tar.gz`,
28
+ sha256: 'a02761a4f189f71c0512e88df7ca2843696d61da659e47f8a5c8a9bd2c0d16f4', // Placeholder - needs actual hash
29
+ },
30
+ 'darwin-arm64': {
31
+ filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-aarch64-apple-darwin-install_only.tar.gz`,
32
+ sha256: '37afe4e77ab62ac50f197b1cb1f3bc02c82735c6be893da0996afcde5dc41048', // Placeholder
33
+ },
34
+ 'linux-x64': {
35
+ filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-x86_64-unknown-linux-gnu-install_only.tar.gz`,
36
+ sha256: '0cac1495fff920219904b1d573aaec0df54d549c226cb45f5c60cb6d2c72727a', // Placeholder
37
+ },
38
+ 'linux-arm64': {
39
+ filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-aarch64-unknown-linux-gnu-install_only.tar.gz`,
40
+ sha256: 'c68280591cda1c9515a04809fa6926020177e8e5892300206e0496ea1d10290e', // Placeholder
41
+ },
42
+ 'win32-x64': {
43
+ filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-x86_64-pc-windows-msvc-install_only.tar.gz`,
44
+ sha256: '8b00014c7c35f9ad4cb1c565f067500bacc4125c8bc30e4389ee0be9fd6ffa3d',
45
+ },
46
+ 'win32-arm64': {
47
+ filename: `cpython-${PYTHON_VERSION}+${PBS_VERSION}-aarch64-pc-windows-msvc-install_only.tar.gz`,
48
+ sha256: '9060d644bd32ac0e0af970d0b21e207e6ff416b7c4dc26ffc4f9b043fb45b463',
49
+ },
50
+ };
51
+
52
+ /**
53
+ * Get the platform key for the current system
54
+ */
55
+ export function getPlatformKey() {
56
+ const platform = process.platform;
57
+ const arch = process.arch;
58
+
59
+ const platformKey = `${platform}-${arch}`;
60
+
61
+ if (!PBS_DISTRIBUTIONS[platformKey]) {
62
+ throw new Error(
63
+ `Unsupported platform: ${platform}-${arch}. ` +
64
+ `Supported platforms: ${Object.keys(PBS_DISTRIBUTIONS).join(', ')}`
65
+ );
66
+ }
67
+
68
+ return platformKey;
69
+ }
70
+
71
+ /**
72
+ * Get cache directory for Python runtime
73
+ * Priority: RUNEVALS_CACHE_DIR > XDG/platform cache > temp
74
+ */
75
+ export function getCacheDir() {
76
+ if (process.env.RUNEVALS_CACHE_DIR) {
77
+ return path.resolve(process.env.RUNEVALS_CACHE_DIR);
78
+ }
79
+
80
+ const homeDir = os.homedir();
81
+
82
+ switch (process.platform) {
83
+ case 'darwin':
84
+ return path.join(homeDir, 'Library', 'Caches', 'm365-copilot-eval');
85
+ case 'win32':
86
+ return path.join(
87
+ process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
88
+ 'm365-copilot-eval'
89
+ );
90
+ default: // Linux and others
91
+ return path.join(
92
+ process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache'),
93
+ 'm365-copilot-eval'
94
+ );
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Calculate SHA256 hash of a file
100
+ */
101
+ async function calculateFileHash(filePath) {
102
+ const hash = crypto.createHash('sha256');
103
+ const stream = (await import('fs')).createReadStream(filePath);
104
+
105
+ for await (const chunk of stream) {
106
+ hash.update(chunk);
107
+ }
108
+
109
+ return hash.digest('hex');
110
+ }
111
+
112
+ /**
113
+ * Download a file with progress indication
114
+ */
115
+ async function downloadFile(url, destPath, expectedHash) {
116
+ console.log(`Downloading: ${url}`);
117
+
118
+ // Support proxy configuration
119
+ const fetchOptions = {};
120
+ if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY) {
121
+ const { HttpsProxyAgent } = await import('https-proxy-agent');
122
+ const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
123
+ fetchOptions.agent = new HttpsProxyAgent(proxy);
124
+ }
125
+
126
+ const response = await fetch(url, fetchOptions);
127
+
128
+ if (!response.ok) {
129
+ throw new Error(
130
+ `Failed to download: ${response.status} ${response.statusText}`
131
+ );
132
+ }
133
+
134
+ // Create parent directory if needed
135
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
136
+
137
+ // Stream to file
138
+ const fileStream = createWriteStream(destPath);
139
+ await pipeline(response.body, fileStream);
140
+
141
+ // Verify checksum
142
+ console.log('Verifying checksum...');
143
+ const actualHash = await calculateFileHash(destPath);
144
+
145
+ if (actualHash !== expectedHash) {
146
+ await fs.unlink(destPath); // Remove corrupted file
147
+ throw new Error(
148
+ `Checksum mismatch!\n` +
149
+ ` Expected: ${expectedHash}\n` +
150
+ ` Actual: ${actualHash}\n` +
151
+ `This could indicate a corrupted download or a supply chain attack.`
152
+ );
153
+ }
154
+
155
+ console.log('Checksum verified ✓');
156
+ }
157
+
158
+ /**
159
+ * Extract tar.gz archive
160
+ */
161
+ async function extractTarGz(archivePath, destDir) {
162
+ const tar = await import('tar');
163
+
164
+ console.log(`Extracting to: ${destDir}`);
165
+ await fs.mkdir(destDir, { recursive: true });
166
+
167
+ await tar.extract({
168
+ file: archivePath,
169
+ cwd: destDir,
170
+ strip: 1, // Remove the top-level directory from archive
171
+ });
172
+
173
+ console.log('Extraction complete ✓');
174
+ }
175
+
176
+ /**
177
+ * Download and setup Python Build Standalone runtime
178
+ * Returns the path to the Python executable
179
+ */
180
+ export async function ensurePythonRuntime(verbose = false) {
181
+ const platformKey = getPlatformKey();
182
+ const distribution = PBS_DISTRIBUTIONS[platformKey];
183
+
184
+ const cacheDir = getCacheDir();
185
+ const pythonDir = path.join(
186
+ cacheDir,
187
+ 'python',
188
+ `${PYTHON_VERSION}-${platformKey}`
189
+ );
190
+ const archivePath = path.join(cacheDir, 'downloads', distribution.filename);
191
+
192
+ // Determine Python executable path based on platform
193
+ let pythonExe;
194
+ if (process.platform === 'win32') {
195
+ pythonExe = path.join(pythonDir, 'python.exe');
196
+ } else {
197
+ pythonExe = path.join(pythonDir, 'bin', 'python3');
198
+ }
199
+
200
+ // Check if Python is already installed
201
+ try {
202
+ await fs.access(pythonExe);
203
+ if (verbose) {
204
+ console.log(`Using cached Python runtime: ${pythonExe}`);
205
+ }
206
+ return pythonExe;
207
+ } catch {
208
+ // Python not found, proceed with download and extraction
209
+ }
210
+
211
+ console.log(
212
+ `Setting up Python ${PYTHON_VERSION} runtime for ${platformKey}...`
213
+ );
214
+
215
+ // Download if not cached
216
+ try {
217
+ await fs.access(archivePath);
218
+ if (verbose) {
219
+ console.log('Using cached download');
220
+ }
221
+ } catch {
222
+ const downloadUrl = `${PBS_BASE_URL}/${distribution.filename}`;
223
+ await downloadFile(downloadUrl, archivePath, distribution.sha256);
224
+ }
225
+
226
+ // Extract
227
+ await extractTarGz(archivePath, pythonDir);
228
+
229
+ // Verify Python executable exists
230
+ await fs.access(pythonExe);
231
+
232
+ console.log(`Python runtime ready: ${pythonExe}`);
233
+ return pythonExe;
234
+ }
235
+
236
+ /**
237
+ * Get Python executable path (assumes runtime is already set up)
238
+ */
239
+ export async function getPythonExecutable() {
240
+ const platformKey = getPlatformKey();
241
+ const cacheDir = getCacheDir();
242
+ const pythonDir = path.join(
243
+ cacheDir,
244
+ 'python',
245
+ `${PYTHON_VERSION}-${platformKey}`
246
+ );
247
+
248
+ if (process.platform === 'win32') {
249
+ return path.join(pythonDir, 'python.exe');
250
+ } else {
251
+ return path.join(pythonDir, 'bin', 'python3');
252
+ }
253
+ }
@@ -0,0 +1,242 @@
1
+ import path from 'path';
2
+ import fs from 'fs/promises';
3
+ import { spawn } from 'child_process';
4
+ import crypto from 'crypto';
5
+ import { getPythonExecutable, getCacheDir } from './python-runtime.js';
6
+
7
+ /**
8
+ * Execute a command and return stdout
9
+ */
10
+ function execCommand(command, args, options = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const proc = spawn(command, args, {
13
+ ...options,
14
+ shell: process.platform === 'win32',
15
+ });
16
+
17
+ let stdout = '';
18
+ let stderr = '';
19
+
20
+ if (proc.stdout) {
21
+ proc.stdout.on('data', (data) => {
22
+ stdout += data.toString();
23
+ if (options.verbose) {
24
+ process.stdout.write(data);
25
+ }
26
+ });
27
+ }
28
+
29
+ if (proc.stderr) {
30
+ proc.stderr.on('data', (data) => {
31
+ stderr += data.toString();
32
+ if (options.verbose) {
33
+ process.stderr.write(data);
34
+ }
35
+ });
36
+ }
37
+
38
+ proc.on('error', reject);
39
+
40
+ proc.on('close', (code) => {
41
+ if (code === 0) {
42
+ resolve({ stdout, stderr, code });
43
+ } else {
44
+ reject(new Error(`Command failed with code ${code}\n${stderr}`));
45
+ }
46
+ });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Calculate hash of requirements file for cache invalidation
52
+ */
53
+ async function getRequirementsHash(requirementsPath) {
54
+ const content = await fs.readFile(requirementsPath, 'utf-8');
55
+ return crypto
56
+ .createHash('sha256')
57
+ .update(content)
58
+ .digest('hex')
59
+ .substring(0, 16);
60
+ }
61
+
62
+ /**
63
+ * Get the venv directory path
64
+ */
65
+ export function getVenvDir() {
66
+ const cacheDir = getCacheDir();
67
+ return path.join(cacheDir, 'venv');
68
+ }
69
+
70
+ /**
71
+ * Get the venv Python executable path
72
+ */
73
+ function getVenvPython(venvDir) {
74
+ if (process.platform === 'win32') {
75
+ return path.join(venvDir, 'Scripts', 'python.exe');
76
+ } else {
77
+ return path.join(venvDir, 'bin', 'python');
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Get the venv pip executable path
83
+ */
84
+ function getVenvPip(venvDir) {
85
+ if (process.platform === 'win32') {
86
+ return path.join(venvDir, 'Scripts', 'pip.exe');
87
+ } else {
88
+ return path.join(venvDir, 'bin', 'pip');
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Create a virtual environment
94
+ */
95
+ async function createVenv(pythonExe, venvDir, verbose = false) {
96
+ if (verbose) {
97
+ console.log(`Creating virtual environment at: ${venvDir}`);
98
+ }
99
+
100
+ await fs.mkdir(venvDir, { recursive: true });
101
+
102
+ await execCommand(pythonExe, ['-m', 'venv', venvDir], { verbose });
103
+
104
+ if (verbose) {
105
+ console.log('Virtual environment created ✓');
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Install packages from requirements.txt into venv
111
+ */
112
+ async function installRequirements(venvDir, requirementsPath, verbose = false) {
113
+ const pipExe = getVenvPip(venvDir);
114
+
115
+ console.log('Installing Python dependencies...');
116
+ if (verbose) {
117
+ console.log(`Using pip: ${pipExe}`);
118
+ console.log(`Requirements: ${requirementsPath}`);
119
+ }
120
+
121
+ // Upgrade pip first
122
+ await execCommand(pipExe, ['install', '--upgrade', 'pip'], { verbose });
123
+
124
+ // Install requirements with hash checking if available
125
+ const args = ['install', '-r', requirementsPath];
126
+
127
+ // Support proxy and certificate configuration
128
+ if (process.env.PIP_CERT) {
129
+ args.push('--cert', process.env.PIP_CERT);
130
+ }
131
+
132
+ if (process.env.PIP_TRUSTED_HOST) {
133
+ args.push('--trusted-host', process.env.PIP_TRUSTED_HOST);
134
+ }
135
+
136
+ await execCommand(pipExe, args, { verbose });
137
+
138
+ console.log('Dependencies installed ✓');
139
+ }
140
+
141
+ /**
142
+ * Check if venv exists and is valid
143
+ */
144
+ async function isVenvValid(venvDir, requirementsHash) {
145
+ try {
146
+ // Check if venv Python exists
147
+ const venvPython = getVenvPython(venvDir);
148
+ await fs.access(venvPython);
149
+
150
+ // Check if requirements hash matches
151
+ const markerFile = path.join(venvDir, '.requirements-hash');
152
+ try {
153
+ const storedHash = await fs.readFile(markerFile, 'utf-8');
154
+ return storedHash.trim() === requirementsHash;
155
+ } catch {
156
+ return false; // Marker file doesn't exist
157
+ }
158
+ } catch {
159
+ return false; // Venv doesn't exist
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Write requirements hash marker
165
+ */
166
+ async function writeRequirementsMarker(venvDir, requirementsHash) {
167
+ const markerFile = path.join(venvDir, '.requirements-hash');
168
+ await fs.writeFile(markerFile, requirementsHash, 'utf-8');
169
+ }
170
+
171
+ /**
172
+ * Ensure virtual environment is set up with all dependencies
173
+ * Returns the path to the venv Python executable
174
+ */
175
+ export async function ensureVenv(requirementsPath, verbose = false) {
176
+ const pythonExe = await getPythonExecutable();
177
+ const venvDir = getVenvDir();
178
+ const requirementsHash = await getRequirementsHash(requirementsPath);
179
+
180
+ // Check if venv is already set up and valid
181
+ if (await isVenvValid(venvDir, requirementsHash)) {
182
+ if (verbose) {
183
+ console.log('Using existing virtual environment');
184
+ }
185
+ return getVenvPython(venvDir);
186
+ }
187
+
188
+ console.log('Setting up Python virtual environment...');
189
+
190
+ // Remove old venv if it exists but is invalid
191
+ try {
192
+ await fs.rm(venvDir, { recursive: true, force: true });
193
+ } catch {
194
+ // Ignore errors during cleanup
195
+ }
196
+
197
+ // Create new venv
198
+ await createVenv(pythonExe, venvDir, verbose);
199
+
200
+ // Install requirements
201
+ await installRequirements(venvDir, requirementsPath, verbose);
202
+
203
+ // Mark as complete
204
+ await writeRequirementsMarker(venvDir, requirementsHash);
205
+
206
+ console.log('Virtual environment ready ✓');
207
+ return getVenvPython(venvDir);
208
+ }
209
+
210
+ /**
211
+ * Execute Python CLI script with the venv Python
212
+ */
213
+ export async function executePythonCli(scriptPath, args = [], options = {}) {
214
+ const venvPython = getVenvPython(getVenvDir());
215
+
216
+ if (!options.quiet) {
217
+ console.log(`Executing Python CLI: ${scriptPath} ${args.join(' ')}`);
218
+ }
219
+
220
+ return new Promise((resolve, reject) => {
221
+ const proc = spawn(venvPython, [scriptPath, ...args], {
222
+ stdio: 'inherit', // Pass through stdin/stdout/stderr for interactive auth
223
+ env: {
224
+ ...process.env,
225
+ // Ensure venv is activated by setting PATH
226
+ PYTHONHOME: '', // Clear PYTHONHOME to avoid conflicts
227
+ },
228
+ cwd: options.cwd || process.cwd(), // Allow custom working directory
229
+ ...options,
230
+ });
231
+
232
+ proc.on('error', reject);
233
+
234
+ proc.on('close', (code) => {
235
+ if (code === 0) {
236
+ resolve(code);
237
+ } else {
238
+ reject(new Error(`Python CLI exited with code ${code}`));
239
+ }
240
+ });
241
+ });
242
+ }