@jerryan/pi-pyvenv 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JerryAZR
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @jerryan/pi-pyvenv
2
+
3
+ A [pi](https://github.com/earendil-works/pi-mono) extension that transparently ensures every `python`, `python3`, `pip`, and `pip3` invocation resolves to a virtual environment — never to the system Python.
4
+
5
+ ## Why
6
+
7
+ Coding agents love running `pip install` against the system Python. This breaks or pollutes the user's system packages. `pi-pyvenv` makes this impossible by automatically activating a venv at the **process level** before the agent starts working.
8
+
9
+ ## What it does
10
+
11
+ On every session start, `pi-pyvenv` picks a venv in this order:
12
+
13
+ 1. **Respect existing activation** — If `VIRTUAL_ENV` is already set (e.g. the user activated a venv in their shell before launching pi), do nothing.
14
+ 2. **Discover project venv** — If `.venv/` or `venv/` exists in the current working directory, activate that.
15
+ 3. **Shared fallback venv** — Otherwise, activate (or create) a shared user-level venv at `~/.pi/agent/pyvenv/`.
16
+
17
+ The activation mutates `process.env.PATH` and `process.env.VIRTUAL_ENV` **once** at the Node.js process level. All subsequently spawned child processes inherit it automatically — no command rewriting, no `spawnHook`, no tool interception.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pi install @jerryan/pi-pyvenv
23
+ ```
24
+
25
+ Or install locally:
26
+
27
+ ```bash
28
+ cd ~/.pi/agent/extensions/
29
+ git clone https://github.com/JerryAZR/pi-pyvenv.git
30
+ cd pi-pyvenv
31
+ npm install
32
+ ```
33
+
34
+ Then restart pi or run `/reload`.
35
+
36
+ ## Commands
37
+
38
+ | Command | Description |
39
+ |---|---|
40
+ | `/pyvenv status` | Show the currently active venv |
41
+ | `/pyvenv recreate` | Delete and rebuild the shared fallback venv |
42
+
43
+ ## What it is NOT
44
+
45
+ This extension is **not** a project-level venv manager. It does not:
46
+
47
+ - Create `.venv/` in your project directory
48
+ - Switch venvs when you change directories
49
+ - Handle `requirements.txt`, `pyproject.toml`, or lockfiles
50
+ - Wrap Conda, Poetry, `uv`, or `pipenv`
51
+
52
+ If you are working on a real Python project, create your own `.venv/` and `pi-pyvenv` will discover and use it automatically.
53
+
54
+ ## Design decisions
55
+
56
+ - **No system prompt injection** — The agent does not need to know a venv is active. This is pure infrastructure.
57
+ - **No command rewriting** — We trust `PATH` precedence. Absolute paths like `/usr/bin/pip` are rare and not worth the fragility of interception.
58
+ - **No `spawnHook`** — Process-level env mutation covers `bash` tool, `!` commands, `pi.exec()`, and other extensions.
59
+ - **MS Store shim detection** — On Windows, broken `python3` shims (exit code 49) are automatically skipped during Python discovery.
60
+ - **Dead venv recovery** — If the shared venv's underlying Python was removed or upgraded, it is detected and recreated on the next session start.
61
+
62
+ ## License
63
+
64
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@jerryan/pi-pyvenv",
3
+ "version": "0.1.0",
4
+ "description": "Automatically activate a Python virtual environment for pi coding agents",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/JerryAZR/pi-pyvenv.git"
8
+ },
9
+ "homepage": "https://github.com/JerryAZR/pi-pyvenv#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/JerryAZR/pi-pyvenv/issues"
12
+ },
13
+ "author": "JerryAZR",
14
+ "license": "MIT",
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "main": "./src/index.ts",
19
+ "types": "./src/index.ts",
20
+ "scripts": {
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "keywords": [
25
+ "pi",
26
+ "pi-extension",
27
+ "python",
28
+ "venv",
29
+ "virtualenv"
30
+ ],
31
+ "files": [
32
+ "src",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "^0.75.4",
41
+ "typescript": "^5.4.0",
42
+ "vitest": "^1.6.0"
43
+ },
44
+ "pi": {
45
+ "extensions": [
46
+ "./src/index.ts"
47
+ ]
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ findProjectVenv,
4
+ ensureSharedVenv,
5
+ activateVenv,
6
+ getActiveVenv,
7
+ getSharedVenvPath,
8
+ } from "./venv.js";
9
+
10
+ export default function (pi: ExtensionAPI) {
11
+ pi.on("session_start", async (_event, ctx) => {
12
+ // 1. Respect existing VIRTUAL_ENV — user already activated something
13
+ if (process.env.VIRTUAL_ENV) {
14
+ return;
15
+ }
16
+
17
+ // 2. Discover project venv in cwd
18
+ const projectVenv = findProjectVenv(ctx.cwd);
19
+ if (projectVenv) {
20
+ activateVenv(projectVenv);
21
+ return;
22
+ }
23
+
24
+ // 3. Fall back to shared venv
25
+ const sharedVenv = await ensureSharedVenv();
26
+ if (sharedVenv) {
27
+ activateVenv(sharedVenv);
28
+ }
29
+ });
30
+
31
+ pi.registerCommand("pyvenv", {
32
+ description: "Show or manage the Python virtual environment",
33
+ handler: async (args, ctx) => {
34
+ const arg = args.trim();
35
+
36
+ if (arg === "recreate") {
37
+ const sharedPath = getSharedVenvPath();
38
+ if (!getActiveVenv()?.startsWith(sharedPath)) {
39
+ ctx.ui.notify("Only the shared fallback venv can be recreated.", "warning");
40
+ return;
41
+ }
42
+
43
+ const ok = await ctx.ui.confirm(
44
+ "Recreate shared venv?",
45
+ `Delete ${sharedPath} and rebuild it?`
46
+ );
47
+ if (!ok) return;
48
+
49
+ const { rm } = await import("node:fs/promises");
50
+ try {
51
+ await rm(sharedPath, { recursive: true, force: true });
52
+ ctx.ui.notify("Shared venv deleted. Rebuilding...", "info");
53
+ } catch (e: any) {
54
+ ctx.ui.notify(`Failed to delete venv: ${e.message}`, "error");
55
+ return;
56
+ }
57
+
58
+ const rebuilt = await ensureSharedVenv();
59
+ if (rebuilt) {
60
+ activateVenv(rebuilt);
61
+ ctx.ui.notify(`Shared venv rebuilt: ${rebuilt}`, "info");
62
+ } else {
63
+ ctx.ui.notify("Failed to rebuild shared venv.", "error");
64
+ }
65
+ return;
66
+ }
67
+
68
+ if (arg === "" || arg === "status") {
69
+ const active = getActiveVenv();
70
+ if (!active) {
71
+ ctx.ui.notify("No Python venv is active.", "warning");
72
+ } else {
73
+ ctx.ui.notify(`Python venv: ${active}`, "info");
74
+ }
75
+ return;
76
+ }
77
+
78
+ ctx.ui.notify(
79
+ `Unknown pyvenv command: "${arg}". Try "status" or "recreate".`,
80
+ "warning"
81
+ );
82
+ },
83
+ });
84
+ }
package/src/venv.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { existsSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { delimiter, join } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+
7
+ const VENV_NAMES = [".venv", "venv"];
8
+ const SHARED_VENV_NAME = "pyvenv";
9
+
10
+ export function findProjectVenv(cwd: string): string | null {
11
+ for (const name of VENV_NAMES) {
12
+ const p = join(cwd, name);
13
+ if (existsSync(p)) return p;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function getSharedVenvPath(): string {
19
+ return join(homedir(), ".pi", "agent", SHARED_VENV_NAME);
20
+ }
21
+
22
+ function getVenvBinDir(venvPath: string): string {
23
+ return process.platform === "win32"
24
+ ? join(venvPath, "Scripts")
25
+ : join(venvPath, "bin");
26
+ }
27
+
28
+ function getVenvPython(venvPath: string): string {
29
+ const binDir = getVenvBinDir(venvPath);
30
+ return process.platform === "win32"
31
+ ? join(binDir, "python.exe")
32
+ : join(binDir, "python");
33
+ }
34
+
35
+ function execFileAsync(
36
+ command: string,
37
+ args: string[],
38
+ timeoutMs = 10000
39
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
40
+ return new Promise((resolve) => {
41
+ try {
42
+ execFile(command, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
43
+ if (error) {
44
+ resolve({
45
+ code: (error as any).code as number ?? 1,
46
+ stdout: stdout.trim(),
47
+ stderr: stderr.trim(),
48
+ });
49
+ } else {
50
+ resolve({ code: 0, stdout: stdout.trim(), stderr: stderr.trim() });
51
+ }
52
+ });
53
+ } catch {
54
+ resolve({ code: 1, stdout: "", stderr: "" });
55
+ }
56
+ });
57
+ }
58
+
59
+ export async function findValidPython(): Promise<string | null> {
60
+ const candidates = ["python3", "python", "py"];
61
+ for (const cmd of candidates) {
62
+ const result = await execFileAsync(cmd, ["-c", "import sys; print(sys.executable)"], 5000);
63
+ if (result.code === 0 && result.stdout.length > 0) {
64
+ return cmd;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ export async function smokeTestVenv(venvPath: string): Promise<boolean> {
71
+ const python = getVenvPython(venvPath);
72
+ if (!existsSync(python)) return false;
73
+ const result = await execFileAsync(python, ["-c", "import sys; print(sys.executable)"], 5000);
74
+ return result.code === 0 && result.stdout.length > 0;
75
+ }
76
+
77
+ export async function createVenv(pythonCmd: string, venvPath: string): Promise<void> {
78
+ const result = await execFileAsync(pythonCmd, ["-m", "venv", venvPath], 60000);
79
+ if (result.code !== 0) {
80
+ throw new Error(
81
+ `Failed to create venv at ${venvPath}: ${result.stderr || result.stdout}`
82
+ );
83
+ }
84
+ }
85
+
86
+ let originalPath: string | undefined;
87
+
88
+ export function activateVenv(venvPath: string): void {
89
+ // If a different venv is already active, do nothing
90
+ if (process.env.VIRTUAL_ENV && process.env.VIRTUAL_ENV !== venvPath) {
91
+ return;
92
+ }
93
+
94
+ const binDir = getVenvBinDir(venvPath);
95
+ const pathKey =
96
+ Object.keys(process.env).find((k) => k.toLowerCase() === "path") ?? "PATH";
97
+ const existingPath = process.env[pathKey] ?? "";
98
+
99
+ // Idempotency: already active
100
+ if (existingPath.startsWith(`${binDir}${delimiter}`)) {
101
+ return;
102
+ }
103
+
104
+ // Store original path on first activation
105
+ if (originalPath === undefined) {
106
+ originalPath = existingPath;
107
+ }
108
+
109
+ const newPath = existingPath
110
+ ? `${binDir}${delimiter}${existingPath}`
111
+ : binDir;
112
+
113
+ process.env[pathKey] = newPath;
114
+ process.env.VIRTUAL_ENV = venvPath;
115
+ }
116
+
117
+ export function getActiveVenv(): string | undefined {
118
+ return process.env.VIRTUAL_ENV;
119
+ }
120
+
121
+ export function deactivateVenv(): void {
122
+ if (originalPath !== undefined) {
123
+ const pathKey =
124
+ Object.keys(process.env).find((k) => k.toLowerCase() === "path") ?? "PATH";
125
+ process.env[pathKey] = originalPath;
126
+ }
127
+ delete process.env.VIRTUAL_ENV;
128
+ }
129
+
130
+ export async function ensureSharedVenv(): Promise<string | null> {
131
+ const sharedPath = getSharedVenvPath();
132
+
133
+ if (existsSync(sharedPath)) {
134
+ if (await smokeTestVenv(sharedPath)) {
135
+ return sharedPath;
136
+ }
137
+ // Dead venv — remove and recreate
138
+ await rm(sharedPath, { recursive: true, force: true });
139
+ }
140
+
141
+ const python = await findValidPython();
142
+ if (!python) return null;
143
+
144
+ await createVenv(python, sharedPath);
145
+ return sharedPath;
146
+ }