Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 116 additions & 3 deletions src/deepwork/cli/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import shutil
from pathlib import Path
from typing import Optional

import click
from rich.console import Console
from rich.prompt import Prompt

from deepwork.core.adapters import AgentAdapter
from deepwork.core.detector import PlatformDetector
from deepwork.utils.fs import ensure_dir
from deepwork.utils.git import is_git_repo
from deepwork.utils.python_env import PythonEnvironment
from deepwork.utils.yaml_utils import load_yaml, save_yaml

console = Console()
Expand Down Expand Up @@ -145,6 +148,42 @@ def _create_tmp_directory(deepwork_dir: Path) -> None:
)


def _ensure_venv_in_gitignore(project_path: Path, venv_path: str) -> None:
"""
Ensure the virtual environment is in the project's .gitignore.

Creates or updates the project's .gitignore file to include the venv path.

Args:
project_path: Path to project root directory
venv_path: Path to virtual environment (e.g., ".venv")
"""
gitignore_path = project_path / ".gitignore"

# Read existing .gitignore if it exists
if gitignore_path.exists():
content = gitignore_path.read_text()
lines = content.splitlines()
else:
content = ""
lines = []

# Check if venv_path is already in .gitignore (with or without trailing slash)
venv_patterns = {venv_path, f"{venv_path}/", f"/{venv_path}", f"/{venv_path}/"}
if any(line.strip() in venv_patterns for line in lines):
return # Already present

# Add venv to .gitignore
if content and not content.endswith("\n"):
content += "\n"

if content:
content += "\n"

content += f"# Python virtual environment (added by DeepWork)\n{venv_path}\n"
gitignore_path.write_text(content)


def _create_rules_directory(project_path: Path) -> bool:
"""
Create the v2 rules directory structure with example templates.
Expand Down Expand Up @@ -226,6 +265,42 @@ def _create_rules_directory(project_path: Path) -> bool:
return True


def _prompt_python_setup(console: Console) -> dict:
"""Prompt user for Python environment preferences.

Args:
console: Rich console for output

Returns:
Dictionary containing python configuration:
- manager: "uv" | "system" | "skip"
- version: Python version string
- venv_path: Path to virtual environment
"""
console.print("\n[bold]Python Environment Setup[/bold]")
console.print("=" * 40)
console.print("\nHow should Python dependencies be managed?\n")

choices_display = [
("1", "uv (Recommended)", "Creates isolated .venv with project-specific Python"),
("2", "System Python", "Uses existing python3 from PATH"),
("3", "Skip", "No Python environment setup"),
]

for key, name, desc in choices_display:
console.print(f" [{key}] {name}")
console.print(f" {desc}\n")

choice = Prompt.ask("Choice", default="1", choices=["1", "2", "3"])

manager_map = {"1": "uv", "2": "system", "3": "skip"}
return {
"manager": manager_map[choice],
"version": "3.11",
"venv_path": ".venv"
}


class DynamicChoice(click.Choice):
"""A Click Choice that gets its values dynamically from AgentAdapter."""

Expand All @@ -248,15 +323,20 @@ def __init__(self) -> None:
default=".",
help="Path to project directory (default: current directory)",
)
def install(platform: str | None, path: Path) -> None:
@click.option(
"--python-manager",
type=click.Choice(["uv", "system", "skip"]),
help="Python environment manager (skips interactive prompt)",
)
def install(platform: str | None, path: Path, python_manager: str | None) -> None:
"""
Install DeepWork in a project.

Adds the specified AI platform to the project configuration and syncs
commands for all configured platforms.
"""
try:
_install_deepwork(platform, path)
_install_deepwork(platform, path, python_manager)
except InstallError as e:
console.print(f"[red]Error:[/red] {e}")
raise click.Abort() from e
Expand All @@ -265,13 +345,14 @@ def install(platform: str | None, path: Path) -> None:
raise


def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
def _install_deepwork(platform_name: str | None, project_path: Path, python_manager: str | None) -> None:
"""
Install DeepWork in a project.

Args:
platform_name: Platform to install for (or None to auto-detect)
project_path: Path to project directory
python_manager: Python environment manager choice (or None to prompt)

Raises:
InstallError: If installation fails
Expand Down Expand Up @@ -335,6 +416,35 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
platforms_to_add.append(adapter.name)
detected_adapters = available_adapters

# Step 2b: Python environment setup
if python_manager:
python_config = {"manager": python_manager, "version": "3.11", "venv_path": ".venv"}
else:
# Check for existing venv
existing = PythonEnvironment.detect_existing(project_path)
if existing:
console.print(f"\n[green]→[/green] Found existing virtual environment: {existing.relative_to(project_path)}")
python_config = {"manager": "skip", "version": "3.11", "venv_path": str(existing.relative_to(project_path))}
else:
python_config = _prompt_python_setup(console)

# Create Python environment
if python_config["manager"] != "skip":
console.print(f"\n[yellow]→[/yellow] Setting up Python environment with {python_config['manager']}...")
env = PythonEnvironment(python_config)
try:
success = env.setup(project_path)
if success:
console.print(" [green]✓[/green] Virtual environment created")
# Ensure venv is in project's .gitignore
_ensure_venv_in_gitignore(project_path, python_config["venv_path"])
console.print(" [green]✓[/green] Added .venv to .gitignore")
else:
console.print(" [yellow]⚠[/yellow] Virtual environment setup returned False")
except RuntimeError as e:
console.print(f" [red]✗[/red] Failed: {e}")
raise InstallError(f"Python environment setup failed: {e}") from e

# Step 3: Create .deepwork/ directory structure
console.print("[yellow]→[/yellow] Creating DeepWork directory structure...")
deepwork_dir = project_path / ".deepwork"
Expand Down Expand Up @@ -393,6 +503,9 @@ def _install_deepwork(platform_name: str | None, project_path: Path) -> None:
else:
console.print(f" [dim]•[/dim] {adapter.display_name} already configured")

# Add python configuration
config_data["python"] = python_config

save_yaml(config_file, config_data)
console.print(f" [green]✓[/green] Updated {config_file.relative_to(project_path)}")

Expand Down
134 changes: 134 additions & 0 deletions src/deepwork/utils/python_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Python environment management utilities."""

import shutil
import subprocess
from pathlib import Path
from typing import Optional


class PythonEnvironment:
"""Manages Python virtual environments."""

def __init__(self, config: dict):
"""Initialize Python environment manager.

Args:
config: Dictionary containing:
- manager: "uv" | "system" | "skip"
- version: Python version string (e.g., "3.11")
- venv_path: Path to virtual environment (e.g., ".venv")
"""
self.manager = config.get("manager", "uv")
self.version = config.get("version", "3.11")
self.venv_path = Path(config.get("venv_path", ".venv"))

def setup(self, project_root: Path) -> bool:
"""Create virtual environment based on configured manager.

Args:
project_root: Path to the project root directory

Returns:
True if setup succeeded, False otherwise

Raises:
RuntimeError: If required tools are not available
"""
if self.manager == "skip":
return True

venv_dir = project_root / self.venv_path

if self.manager == "uv":
return self._setup_with_uv(venv_dir, project_root)
elif self.manager == "system":
return self._setup_with_system(venv_dir)

return False

def _setup_with_uv(self, venv_dir: Path, project_root: Path) -> bool:
"""Create venv using uv.

Args:
venv_dir: Path where virtual environment should be created
project_root: Path to the project root directory

Returns:
True if creation succeeded, False otherwise

Raises:
RuntimeError: If uv is not found
"""
if not shutil.which("uv"):
raise RuntimeError("uv not found. Install via: brew install uv")

# Initialize pyproject.toml if it doesn't exist
pyproject_path = project_root / "pyproject.toml"
if not pyproject_path.exists():
init_cmd = ["uv", "init", "--no-workspace"]
init_result = subprocess.run(init_cmd, capture_output=True, text=True, cwd=project_root)
if init_result.returncode != 0:
# Non-fatal: Continue even if init fails
pass

cmd = ["uv", "venv", str(venv_dir), "--python", self.version]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0

def _setup_with_system(self, venv_dir: Path) -> bool:
"""Create venv using system Python.

Args:
venv_dir: Path where virtual environment should be created

Returns:
True if creation succeeded, False otherwise

Raises:
RuntimeError: If Python is not found
"""
python = shutil.which("python3") or shutil.which("python")
if not python:
raise RuntimeError("Python not found in PATH")

cmd = [python, "-m", "venv", str(venv_dir)]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0

def install_package(self, package: str, project_root: Path) -> bool:
"""Install a package into the virtual environment.

Args:
package: Package name to install
project_root: Path to the project root directory

Returns:
True if installation succeeded, False otherwise
"""
venv_dir = project_root / self.venv_path

if self.manager == "uv":
cmd = ["uv", "pip", "install", package]
else:
pip = venv_dir / "bin" / "pip"
cmd = [str(pip), "install", package]

result = subprocess.run(cmd, capture_output=True, text=True, cwd=project_root)
return result.returncode == 0

@staticmethod
def detect_existing(project_root: Path) -> Optional[Path]:
"""Detect existing virtual environment.

Args:
project_root: Path to the project root directory

Returns:
Path to detected virtual environment, or None if not found
"""
candidates = [".venv", "venv", ".virtualenv"]
for name in candidates:
venv_dir = project_root / name
if (venv_dir / "bin" / "python").exists():
return venv_dir
return None
16 changes: 8 additions & 8 deletions tests/integration/test_install_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def test_install_with_claude(self, mock_claude_project: Path) -> None:

result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
catch_exceptions=False,
)

Expand Down Expand Up @@ -63,7 +63,7 @@ def test_install_with_auto_detect(self, mock_claude_project: Path) -> None:
runner = CliRunner()

result = runner.invoke(
cli, ["install", "--path", str(mock_claude_project)], catch_exceptions=False
cli, ["install", "--path", str(mock_claude_project), "--python-manager", "skip"], catch_exceptions=False
)

assert result.exit_code == 0
Expand All @@ -84,7 +84,7 @@ def test_install_defaults_to_claude_when_no_platform(self, mock_git_repo: Path)
runner = CliRunner()

result = runner.invoke(
cli, ["install", "--path", str(mock_git_repo)], catch_exceptions=False
cli, ["install", "--path", str(mock_git_repo), "--python-manager", "skip"], catch_exceptions=False
)

assert result.exit_code == 0
Expand Down Expand Up @@ -114,7 +114,7 @@ def test_install_with_multiple_platforms_auto_detect(

result = runner.invoke(
cli,
["install", "--path", str(mock_multi_platform_project)],
["install", "--path", str(mock_multi_platform_project), "--python-manager", "skip"],
catch_exceptions=False,
)

Expand Down Expand Up @@ -162,15 +162,15 @@ def test_install_is_idempotent(self, mock_claude_project: Path) -> None:
# First install
result1 = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
catch_exceptions=False,
)
assert result1.exit_code == 0

# Second install
result2 = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
catch_exceptions=False,
)
assert result2.exit_code == 0
Expand All @@ -191,7 +191,7 @@ def test_install_creates_rules_directory(self, mock_claude_project: Path) -> Non

result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
catch_exceptions=False,
)

Expand Down Expand Up @@ -231,7 +231,7 @@ def test_install_preserves_existing_rules_directory(self, mock_claude_project: P

result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
["install", "--platform", "claude", "--path", str(mock_claude_project), "--python-manager", "skip"],
catch_exceptions=False,
)

Expand Down
Loading