diff --git a/.claude/settings.json b/.claude/settings.json index f9c1cace..7b2ba1a6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -161,7 +161,7 @@ "hooks": [ { "type": "command", - "command": ".deepwork/jobs/deepwork_rules/hooks/user_prompt_submit.sh" + "command": "deepwork hook user_prompt_submit" } ] } diff --git a/.deepwork/jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh b/.deepwork/jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh deleted file mode 100755 index c9cedd82..00000000 --- a/.deepwork/jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# capture_prompt_work_tree.sh - Captures the git work tree state at prompt submission -# -# This script creates a snapshot of ALL tracked files at the time the prompt -# is submitted. This baseline is used for rules with compare_to: prompt and -# created: mode to detect truly NEW files (not modifications to existing ones). -# -# The baseline contains ALL tracked files (not just changed files) so that -# the rules_check hook can determine which files are genuinely new vs which -# files existed before and were just modified. -# -# It also captures the HEAD commit ref so that committed changes can be detected -# by comparing HEAD at Stop time to the captured ref. - -set -e - -# Ensure .deepwork directory exists -mkdir -p .deepwork - -# Save the current HEAD commit ref for detecting committed changes -# This is used by get_changed_files_prompt() to detect files changed since prompt, -# even if those changes were committed during the agent response. -git rev-parse HEAD > .deepwork/.last_head_ref 2>/dev/null || echo "" > .deepwork/.last_head_ref - -# Save ALL tracked files (not just changed files) -# This is critical for created: mode rules to distinguish between: -# - Newly created files (not in baseline) -> should trigger created: rules -# - Modified existing files (in baseline) -> should NOT trigger created: rules -git ls-files > .deepwork/.last_work_tree 2>/dev/null || true - -# Also include untracked files that exist at prompt time -# These are files the user may have created before submitting the prompt -git ls-files --others --exclude-standard >> .deepwork/.last_work_tree 2>/dev/null || true - -# Sort and deduplicate -if [ -f .deepwork/.last_work_tree ]; then - sort -u .deepwork/.last_work_tree -o .deepwork/.last_work_tree -fi diff --git a/.deepwork/jobs/deepwork_rules/hooks/global_hooks.yml b/.deepwork/jobs/deepwork_rules/hooks/global_hooks.yml index a310d31a..ee280632 100644 --- a/.deepwork/jobs/deepwork_rules/hooks/global_hooks.yml +++ b/.deepwork/jobs/deepwork_rules/hooks/global_hooks.yml @@ -1,8 +1,11 @@ # DeepWork Rules Hooks Configuration # Maps lifecycle events to hook scripts or Python modules +# +# All hooks use Python modules for cross-platform compatibility (Windows, macOS, Linux). +# The module syntax ensures hooks work regardless of how DeepWork was installed. UserPromptSubmit: - - user_prompt_submit.sh + - module: deepwork.hooks.user_prompt_submit Stop: - module: deepwork.hooks.rules_check diff --git a/.deepwork/jobs/deepwork_rules/hooks/user_prompt_submit.sh b/.deepwork/jobs/deepwork_rules/hooks/user_prompt_submit.sh deleted file mode 100755 index 486ad836..00000000 --- a/.deepwork/jobs/deepwork_rules/hooks/user_prompt_submit.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# user_prompt_submit.sh - Runs on every user prompt submission -# -# This script captures the work tree state at each prompt submission. -# This baseline is used for policies with compare_to: prompt to detect -# what changed during an agent response. - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Capture work tree state at each prompt for compare_to: prompt policies -"${SCRIPT_DIR}/capture_prompt_work_tree.sh" - -# Exit successfully - don't block the prompt -exit 0 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 00000000..3c72038f --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,68 @@ +name: Build Windows Executable + +on: + release: + types: [published] + # Allow manual trigger for testing + workflow_dispatch: + inputs: + upload_to_release: + description: 'Upload to latest release' + type: boolean + default: false + +permissions: + contents: write + +jobs: + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install -e . + + - name: Build Windows executable + run: | + pyinstaller scripts/deepwork.spec --distpath dist --clean -y + + - name: Test executable + run: | + .\dist\deepwork.exe --version + .\dist\deepwork.exe --help + + - name: Rename executable for release + run: | + Move-Item dist\deepwork.exe dist\deepwork-windows-x64.exe + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: deepwork-windows-x64 + path: dist/deepwork-windows-x64.exe + + - name: Upload to Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: dist/deepwork-windows-x64.exe + tag_name: ${{ github.event.release.tag_name }} + + - name: Upload to Latest Release (manual) + if: github.event_name == 'workflow_dispatch' && inputs.upload_to_release + run: | + # Get latest release tag + $latestTag = gh release list --limit 1 --json tagName -q '.[0].tagName' + Write-Host "Uploading to release: $latestTag" + gh release upload $latestTag dist/deepwork-windows-x64.exe --clobber + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index 8e9811ad..1ceede8b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ env/ # PyInstaller *.manifest *.spec +# But keep our build spec file +!scripts/deepwork.spec # Unit test / coverage reports htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca4fd3f..1bd91155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### Removed + +## [0.6.0] - 2026-01-26 + +### Added +- Windows support for Claude Code integration + - PyInstaller spec for building standalone Windows executable (`deepwork.exe`) + - PowerShell installer script that downloads and adds DeepWork to PATH + - GitHub Actions workflow for automated Windows builds on release + - Windows installation documentation at `doc/installation/windows.md` +- Cross-platform Python hook modules replacing bash scripts + - `capture_prompt.py` - Git work tree state capture + - `user_prompt_submit.py` - User prompt submission hook + - `hook_entry.py` - Direct Python entry point for hooks +- New tests for cross-platform hook functionality + +### Changed +- Hooks now use `deepwork hook ` command format instead of bash wrapper scripts +- `global_hooks.yml` updated to use Python modules for cross-platform compatibility +- `hooks_syncer.py` now generates forward-slash paths for bash compatibility on all platforms + +### Removed +- Bash hook scripts (`user_prompt_submit.sh`, `capture_prompt_work_tree.sh`) replaced by Python modules + ## [0.5.1] - 2026-01-24 ### Added diff --git a/README.md b/README.md index 76a659de..cd778a5e 100644 --- a/README.md +++ b/README.md @@ -143,11 +143,11 @@ To start the process, just run: | Platform | Status | Notes | |----------|--------|-------| -| **Claude Code** | Full Support | Recommended. Quality hooks, rules, best DX. | +| **Claude Code** | Full Support | Recommended. Works on Windows, macOS, Linux. Quality hooks, rules, best DX. | | **Gemini CLI** | Partial Support | TOML format, global hooks only | | OpenCode | Planned | | | GitHub Copilot CLI | Planned | | -| Others | Planned | We are nailing Claude and Gemini first, then adding others according ot demand | +| Others | Planned | We are nailing Claude and Gemini first, then adding others according to demand | **Tip:** Use the terminal (Claude Code CLI), not the VS Code extension. The terminal has full feature support. @@ -236,6 +236,38 @@ deepwork install +
+Windows Installation + +DeepWork fully supports Windows with Claude Code. Install using PowerShell: + +```powershell +# Option 1: Download and run the installer (recommended) +Invoke-WebRequest -Uri "https://github.com/unsupervisedcom/deepwork/releases/latest/download/install-windows.ps1" -OutFile install-windows.ps1 +.\install-windows.ps1 + +# Option 2: Using pip (requires Python 3.11+) +pip install deepwork + +# Option 3: Using pipx +pipx install deepwork +``` + +Then in your project folder: + +```powershell +deepwork install +``` + +**Requirements:** +- Windows 10 or later +- Git for Windows (provides Git Bash for hook execution) +- Claude Code installed via npm or winget + +See [doc/installation/windows.md](doc/installation/windows.md) for detailed instructions and troubleshooting. + +
+
Advanced: Automated Rules diff --git a/doc/architecture.md b/doc/architecture.md index 87532d09..e12394c4 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -52,11 +52,11 @@ deepwork/ # DeepWork tool repository │ │ ├── rules_queue.py # Rule state queue system │ │ ├── command_executor.py # Command action execution │ │ └── hooks_syncer.py # Hook syncing to platforms -│ ├── hooks/ # Hook system and cross-platform wrappers +│ ├── hooks/ # Hook system (cross-platform Python modules) │ │ ├── __init__.py │ │ ├── wrapper.py # Cross-platform input/output normalization -│ │ ├── claude_hook.sh # Shell wrapper for Claude Code -│ │ ├── gemini_hook.sh # Shell wrapper for Gemini CLI +│ │ ├── capture_prompt.py # Git work tree state capture +│ │ ├── user_prompt_submit.py # User prompt submission hook │ │ └── rules_check.py # Cross-platform rule evaluation hook │ ├── templates/ # Skill templates for each platform │ │ ├── claude/ @@ -74,10 +74,8 @@ deepwork/ # DeepWork tool repository │ │ ├── job.yml │ │ ├── steps/ │ │ │ └── define.md -│ │ └── hooks/ # Hook scripts -│ │ ├── global_hooks.yml -│ │ ├── user_prompt_submit.sh -│ │ └── capture_prompt_work_tree.sh +│ │ └── hooks/ # Hook configuration (Python modules) +│ │ └── global_hooks.yml │ ├── schemas/ # Definition schemas │ │ ├── job_schema.py │ │ ├── doc_spec_schema.py # Doc spec schema definition @@ -311,10 +309,8 @@ my-project/ # User's project (target) │ │ ├── job.yml │ │ ├── steps/ │ │ │ └── define.md -│ │ └── hooks/ # Hook scripts (installed from standard_jobs) -│ │ ├── global_hooks.yml -│ │ ├── user_prompt_submit.sh -│ │ └── capture_prompt_work_tree.sh +│ │ └── hooks/ # Hook configuration (uses Python modules) +│ │ └── global_hooks.yml │ ├── competitive_research/ │ │ ├── job.yml # Job metadata │ │ └── steps/ @@ -1126,19 +1122,19 @@ This prevents re-prompting for the same rule violation within a session. ### Hook Integration -The v2 rules system uses the cross-platform hook wrapper: +The v2 rules system uses cross-platform Python modules: ``` src/deepwork/hooks/ -├── wrapper.py # Cross-platform input/output normalization -├── rules_check.py # Rule evaluation hook (v2) -├── claude_hook.sh # Claude Code shell wrapper -└── gemini_hook.sh # Gemini CLI shell wrapper +├── wrapper.py # Cross-platform input/output normalization +├── rules_check.py # Rule evaluation hook (v2) +├── user_prompt_submit.py # Prompt submission hook +└── capture_prompt.py # Git work tree state capture ``` -Hooks are called via the shell wrappers: +Hooks are called via the `deepwork hook` CLI command, which works on all platforms (Windows, macOS, Linux): ```bash -claude_hook.sh deepwork.hooks.rules_check +deepwork hook rules_check ``` The hooks are installed to `.claude/settings.json` during `deepwork sync`: @@ -1153,9 +1149,9 @@ The hooks are installed to `.claude/settings.json` during `deepwork sync`: } ``` -### Cross-Platform Hook Wrapper System +### Cross-Platform Hook System -The `hooks/` module provides a wrapper system that allows writing hooks once in Python and running them on multiple platforms. This normalizes the differences between Claude Code and Gemini CLI hook systems. +The `hooks/` module provides Python-based hooks that work on all platforms (Windows, macOS, Linux). Hooks are invoked via the `deepwork hook ` CLI command, which normalizes differences between Claude Code and Gemini CLI. **Architecture:** ``` @@ -1165,12 +1161,10 @@ The `hooks/` module provides a wrapper system that allows writing hooks once in └────────┬────────┘ └────────┬────────┘ │ │ ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ -│ claude_hook.sh │ │ gemini_hook.sh │ -│ (shell wrapper) │ │ (shell wrapper) │ -└────────┬────────┘ └────────┬────────┘ - │ │ - └───────────┬───────────┘ +┌─────────────────────────────────────────┐ +│ deepwork hook │ +│ (cross-platform CLI command) │ +└────────────────────┬────────────────────┘ ▼ ┌─────────────────┐ │ wrapper.py │ @@ -1198,7 +1192,7 @@ def my_hook(input: HookInput) -> HookOutput: return HookOutput(decision="block", reason="Complete X first") return HookOutput() -# Called via: claude_hook.sh mymodule or gemini_hook.sh mymodule +# Called via: deepwork hook mymodule ``` See `doc/platforms/` for detailed platform-specific hook documentation. diff --git a/doc/installation/windows.md b/doc/installation/windows.md new file mode 100644 index 00000000..83adf28e --- /dev/null +++ b/doc/installation/windows.md @@ -0,0 +1,161 @@ +# DeepWork Windows Installation Guide + +This guide covers installing DeepWork on Windows for use with Claude Code. + +## Prerequisites + +- **Windows 10 or later** +- **Claude Code** - Install via [official instructions](https://code.claude.com/docs/en/setup) +- **Git for Windows** - Required for Claude Code hooks (provides Git Bash) + +## Installation Methods + +### Method 1: Pre-built Executable (Recommended) + +Download and run the Windows installer: + +```powershell +# Download the installer +Invoke-WebRequest -Uri "https://github.com/unsupervisedcom/deepwork/releases/latest/download/install-windows.ps1" -OutFile install-windows.ps1 + +# Run the installer +.\install-windows.ps1 +``` + +The installer will: +1. Download the latest `deepwork.exe` +2. Install it to `%LOCALAPPDATA%\DeepWork\bin` +3. Add the installation directory to your PATH + +### Method 2: pip/pipx Installation + +If you have Python 3.11+ installed: + +```powershell +# Using pip (requires Python in PATH) +pip install deepwork + +# OR using pipx (isolated environment, recommended) +pipx install deepwork +``` + +### Method 3: Build from Source + +For developers or custom builds: + +```powershell +# Clone the repository +git clone https://github.com/unsupervisedcom/deepwork.git +cd deepwork + +# Build Windows executable +.\scripts\build-windows.ps1 + +# Install the built executable +.\scripts\install-windows.ps1 -ExePath .\dist\deepwork.exe +``` + +## Verifying Installation + +After installation, open a new terminal and verify: + +```powershell +deepwork --version +deepwork --help +``` + +## Using with Claude Code + +### Initial Setup + +1. Navigate to your project directory +2. Install DeepWork in the project: + +```powershell +cd your-project +deepwork install +``` + +3. Sync skills and hooks: + +```powershell +deepwork sync +``` + +### How Hooks Work on Windows + +Claude Code on Windows runs hooks through Git Bash (installed with Git for Windows). DeepWork hooks are configured to work cross-platform: + +- Hooks use `deepwork hook ` commands +- Git Bash can find `deepwork` if it's in your Windows PATH +- All hook scripts are Python-based for cross-platform compatibility + +### Troubleshooting + +#### "deepwork" command not found + +Ensure the installation directory is in your PATH: + +```powershell +# Check if deepwork is in PATH +where.exe deepwork + +# If not found, add manually +$env:PATH += ";$env:LOCALAPPDATA\DeepWork\bin" + +# To make permanent, add to your PowerShell profile: +Add-Content $PROFILE "`n`$env:PATH += `";`$env:LOCALAPPDATA\DeepWork\bin`"" +``` + +#### Hooks not running + +1. Verify Claude Code is using Git Bash: + ```powershell + $env:CLAUDE_CODE_GIT_BASH_PATH = "C:\Program Files\Git\bin\bash.exe" + ``` + +2. Re-sync hooks: + ```powershell + deepwork sync + ``` + +3. Restart Claude Code session + +#### Permission errors + +Run PowerShell as Administrator for installation, or use the user-level installation (default). + +## Architecture Notes + +### Why Python Modules for Hooks? + +DeepWork uses Python module-based hooks (`deepwork hook `) instead of shell scripts for cross-platform compatibility: + +- Works on Windows, macOS, and Linux +- No bash script dependencies +- Consistent behavior across platforms + +### PATH Considerations + +The Windows installer adds DeepWork to your user PATH. Git Bash inherits the Windows PATH, so `deepwork` commands work in both: + +- PowerShell/CMD: `deepwork --help` +- Git Bash: `deepwork --help` +- Claude Code hooks: Automatically use Git Bash + +## Uninstallation + +```powershell +# Remove the executable +Remove-Item "$env:LOCALAPPDATA\DeepWork" -Recurse -Force + +# Remove from PATH (manual step in System Properties > Environment Variables) +``` + +For pip/pipx installations: + +```powershell +pip uninstall deepwork +# OR +pipx uninstall deepwork +``` diff --git a/pyproject.toml b/pyproject.toml index c2bc3e4a..57d7de93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deepwork" -version = "0.5.1" +version = "0.6.0" description = "Framework for enabling AI agents to perform complex, multi-step work tasks" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 00000000..27bbd356 --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,141 @@ +<# +.SYNOPSIS + Build DeepWork Windows executable + +.DESCRIPTION + This script builds a standalone Windows executable for DeepWork using PyInstaller. + The resulting executable can be distributed and run without Python installed. + +.PARAMETER OutputDir + Directory for the output executable (default: dist/) + +.EXAMPLE + .\build-windows.ps1 + Builds the DeepWork executable in the dist/ directory. + +.NOTES + Requires Python 3.11+ and PyInstaller to be installed. +#> + +param( + [string]$OutputDir = "dist" +) + +$ErrorActionPreference = "Stop" + +function Write-Status { + param([string]$Message) + Write-Host "[*] $Message" -ForegroundColor Cyan +} + +function Write-Success { + param([string]$Message) + Write-Host "[+] $Message" -ForegroundColor Green +} + +function Write-Error { + param([string]$Message) + Write-Host "[-] $Message" -ForegroundColor Red +} + +# Get script directory and project root +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectRoot = Split-Path -Parent $ScriptDir + +Write-Host "" +Write-Host "========================================" -ForegroundColor Magenta +Write-Host " DeepWork Windows Build Script " -ForegroundColor Magenta +Write-Host "========================================" -ForegroundColor Magenta +Write-Host "" + +# Check Python version +Write-Status "Checking Python version..." +try { + $pythonVersion = python --version 2>&1 + Write-Host " Found: $pythonVersion" -ForegroundColor Gray + + # Extract version number + if ($pythonVersion -match "Python (\d+)\.(\d+)") { + $major = [int]$Matches[1] + $minor = [int]$Matches[2] + if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 11)) { + Write-Error "Python 3.11 or higher is required (found $major.$minor)" + exit 1 + } + } +} +catch { + Write-Error "Python not found. Please install Python 3.11 or higher." + exit 1 +} + +# Check/install PyInstaller +Write-Status "Checking PyInstaller..." +$pyinstallerInstalled = python -c "import PyInstaller" 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Status "Installing PyInstaller..." + pip install pyinstaller + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install PyInstaller" + exit 1 + } +} + +# Install DeepWork dependencies +Write-Status "Installing DeepWork dependencies..." +Push-Location $ProjectRoot +try { + pip install -e . + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install DeepWork dependencies" + exit 1 + } +} +finally { + Pop-Location +} + +# Build executable +Write-Status "Building Windows executable..." +$specFile = Join-Path $ScriptDir "deepwork.spec" +Push-Location $ProjectRoot +try { + pyinstaller $specFile --distpath $OutputDir --clean -y + if ($LASTEXITCODE -ne 0) { + Write-Error "PyInstaller build failed" + exit 1 + } +} +finally { + Pop-Location +} + +# Verify output +$exePath = Join-Path $ProjectRoot "$OutputDir\deepwork.exe" +if (-not (Test-Path $exePath)) { + Write-Error "Build completed but executable not found at: $exePath" + exit 1 +} + +# Test the executable +Write-Status "Verifying build..." +try { + $version = & $exePath --version 2>&1 + Write-Success "Build successful!" + Write-Host " Executable: $exePath" -ForegroundColor Gray + Write-Host " Version: $version" -ForegroundColor Gray +} +catch { + Write-Error "Build verification failed: $_" + exit 1 +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Green +Write-Host " Build Complete! " -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "" +Write-Host "Next steps:" -ForegroundColor White +Write-Host " 1. Test: .\$OutputDir\deepwork.exe --help" -ForegroundColor Gray +Write-Host " 2. Install: .\scripts\install-windows.ps1 -ExePath .\$OutputDir\deepwork.exe" -ForegroundColor Gray +Write-Host "" diff --git a/scripts/deepwork.spec b/scripts/deepwork.spec new file mode 100644 index 00000000..a63a5712 --- /dev/null +++ b/scripts/deepwork.spec @@ -0,0 +1,95 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec file for building DeepWork Windows executable. + +Build with: pyinstaller scripts/deepwork.spec + +This creates a standalone Windows executable that can be distributed +without requiring Python to be installed. +""" + +import sys +from pathlib import Path + +# Get the project root (parent of scripts/) +project_root = Path(SPECPATH).parent +src_path = project_root / 'src' + +block_cipher = None + +a = Analysis( + [str(src_path / 'deepwork' / 'cli' / 'main.py')], + pathex=[str(src_path)], + binaries=[], + datas=[ + # Include templates and standard jobs + (str(src_path / 'deepwork' / 'templates'), 'deepwork/templates'), + (str(src_path / 'deepwork' / 'standard_jobs'), 'deepwork/standard_jobs'), + (str(src_path / 'deepwork' / 'schemas'), 'deepwork/schemas'), + (str(src_path / 'deepwork' / 'hooks'), 'deepwork/hooks'), + ], + hiddenimports=[ + 'deepwork', + 'deepwork.cli', + 'deepwork.cli.main', + 'deepwork.cli.install', + 'deepwork.cli.sync', + 'deepwork.cli.hook', + 'deepwork.cli.rules', + 'deepwork.core', + 'deepwork.core.adapters', + 'deepwork.core.detector', + 'deepwork.core.hooks_syncer', + 'deepwork.core.job', + 'deepwork.core.registry', + 'deepwork.core.skill', + 'deepwork.hooks', + 'deepwork.hooks.wrapper', + 'deepwork.hooks.rules_check', + 'deepwork.utils', + 'deepwork.utils.fs', + 'deepwork.utils.git', + 'click', + 'rich', + 'rich.console', + 'rich.table', + 'rich.panel', + 'jinja2', + 'yaml', + 'jsonschema', + 'git', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='deepwork', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=None, # TODO: Add icon when available +) diff --git a/scripts/install-windows.bat b/scripts/install-windows.bat new file mode 100644 index 00000000..21cb596f --- /dev/null +++ b/scripts/install-windows.bat @@ -0,0 +1,30 @@ +@echo off +REM DeepWork Windows Installer (Batch wrapper) +REM This script calls the PowerShell installer with appropriate execution policy + +echo. +echo DeepWork Windows Installer +echo ========================== +echo. + +REM Check if PowerShell is available +where powershell >nul 2>nul +if %ERRORLEVEL% neq 0 ( + echo ERROR: PowerShell is required but not found. + echo Please install PowerShell or use an alternative installation method. + exit /b 1 +) + +REM Get the directory of this script +set SCRIPT_DIR=%~dp0 + +REM Run the PowerShell installer +powershell -ExecutionPolicy Bypass -File "%SCRIPT_DIR%install-windows.ps1" %* + +if %ERRORLEVEL% neq 0 ( + echo. + echo Installation failed with error code %ERRORLEVEL% + exit /b %ERRORLEVEL% +) + +exit /b 0 diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 new file mode 100644 index 00000000..8f12cb55 --- /dev/null +++ b/scripts/install-windows.ps1 @@ -0,0 +1,204 @@ +<# +.SYNOPSIS + DeepWork Windows Installer + +.DESCRIPTION + This script installs DeepWork on Windows by: + 1. Downloading or using a local deepwork.exe + 2. Installing it to %LOCALAPPDATA%\DeepWork\bin + 3. Adding the installation directory to the user's PATH + +.PARAMETER ExePath + Optional path to a local deepwork.exe file to install. + If not provided, the script will try to download from GitHub releases. + +.PARAMETER Version + Version to install (default: latest). + Only used when downloading from GitHub. + +.EXAMPLE + .\install-windows.ps1 + Installs the latest version of DeepWork. + +.EXAMPLE + .\install-windows.ps1 -ExePath .\deepwork.exe + Installs DeepWork from a local executable. + +.NOTES + Requires Windows 10 or later. + Requires PowerShell 5.1 or later. +#> + +param( + [string]$ExePath = "", + [string]$Version = "latest" +) + +$ErrorActionPreference = "Stop" + +# Configuration +$AppName = "DeepWork" +$ExeName = "deepwork.exe" +$InstallDir = Join-Path $env:LOCALAPPDATA "DeepWork\bin" +$GithubRepo = "unsupervisedcom/deepwork" + +function Write-Status { + param([string]$Message) + Write-Host "[*] $Message" -ForegroundColor Cyan +} + +function Write-Success { + param([string]$Message) + Write-Host "[+] $Message" -ForegroundColor Green +} + +function Write-Error { + param([string]$Message) + Write-Host "[-] $Message" -ForegroundColor Red +} + +function Test-Administrator { + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($currentUser) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Add-ToUserPath { + param([string]$Directory) + + $currentPath = [Environment]::GetEnvironmentVariable("PATH", "User") + + if ($currentPath -split ";" | Where-Object { $_ -eq $Directory }) { + Write-Status "$Directory is already in PATH" + return $false + } + + $newPath = if ($currentPath) { "$currentPath;$Directory" } else { $Directory } + [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") + + # Also update current session + $env:PATH = "$env:PATH;$Directory" + + Write-Success "Added $Directory to user PATH" + return $true +} + +function Get-LatestReleaseUrl { + param([string]$Repo) + + $apiUrl = "https://api.github.com/repos/$Repo/releases/latest" + + try { + $release = Invoke-RestMethod -Uri $apiUrl -Headers @{ "User-Agent" = "DeepWork-Installer" } + $asset = $release.assets | Where-Object { $_.name -eq "deepwork-windows-x64.exe" -or $_.name -eq "deepwork.exe" } | Select-Object -First 1 + + if ($asset) { + return @{ + Url = $asset.browser_download_url + Version = $release.tag_name + } + } + } + catch { + Write-Error "Failed to fetch release info: $_" + } + + return $null +} + +function Install-DeepWork { + Write-Host "" + Write-Host "========================================" -ForegroundColor Magenta + Write-Host " DeepWork Windows Installer " -ForegroundColor Magenta + Write-Host "========================================" -ForegroundColor Magenta + Write-Host "" + + # Create installation directory + if (-not (Test-Path $InstallDir)) { + Write-Status "Creating installation directory: $InstallDir" + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + } + + $targetPath = Join-Path $InstallDir $ExeName + + if ($ExePath) { + # Install from local file + if (-not (Test-Path $ExePath)) { + Write-Error "Executable not found: $ExePath" + exit 1 + } + + Write-Status "Installing from local file: $ExePath" + Copy-Item -Path $ExePath -Destination $targetPath -Force + } + else { + # Download from GitHub + Write-Status "Fetching latest release information..." + $releaseInfo = Get-LatestReleaseUrl -Repo $GithubRepo + + if (-not $releaseInfo) { + Write-Error "Could not find a Windows release. Please build from source or provide -ExePath" + Write-Host "" + Write-Host "To build from source:" -ForegroundColor Yellow + Write-Host " 1. Install Python 3.11+" -ForegroundColor Yellow + Write-Host " 2. pip install pyinstaller" -ForegroundColor Yellow + Write-Host " 3. pyinstaller scripts/deepwork.spec" -ForegroundColor Yellow + Write-Host " 4. Run this script with: .\install-windows.ps1 -ExePath dist\deepwork.exe" -ForegroundColor Yellow + exit 1 + } + + Write-Status "Downloading DeepWork $($releaseInfo.Version)..." + + try { + Invoke-WebRequest -Uri $releaseInfo.Url -OutFile $targetPath -UseBasicParsing + } + catch { + Write-Error "Failed to download: $_" + exit 1 + } + } + + # Verify installation + if (-not (Test-Path $targetPath)) { + Write-Error "Installation failed - executable not found" + exit 1 + } + + Write-Success "Installed to: $targetPath" + + # Add to PATH + Write-Status "Updating PATH..." + $pathUpdated = Add-ToUserPath -Directory $InstallDir + + # Verify it works + Write-Status "Verifying installation..." + try { + $versionOutput = & $targetPath --version 2>&1 + Write-Success "DeepWork installed successfully!" + Write-Host " Version: $versionOutput" -ForegroundColor Gray + } + catch { + Write-Error "Installation completed but verification failed: $_" + } + + Write-Host "" + Write-Host "========================================" -ForegroundColor Green + Write-Host " Installation Complete! " -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + + if ($pathUpdated) { + Write-Host "IMPORTANT: Restart your terminal or run the following to use deepwork:" -ForegroundColor Yellow + Write-Host "" + Write-Host " `$env:PATH = `"$env:PATH`"" -ForegroundColor Cyan + Write-Host "" + } + + Write-Host "Quick start:" -ForegroundColor White + Write-Host " deepwork --help # Show available commands" -ForegroundColor Gray + Write-Host " deepwork install # Install DeepWork in a project" -ForegroundColor Gray + Write-Host "" +} + +# Run installer +Install-DeepWork diff --git a/src/deepwork/core/adapters.py b/src/deepwork/core/adapters.py index db859ebb..4b85ecbd 100644 --- a/src/deepwork/core/adapters.py +++ b/src/deepwork/core/adapters.py @@ -462,15 +462,14 @@ def sync_permissions(self, project_path: Path) -> int: """ # Define required permissions for DeepWork functionality # Uses ./ prefix for paths relative to project root (per Claude Code docs) + # Note: Forward slashes work on all platforms including Windows required_permissions = [ # Full access to .deepwork directory "Read(./.deepwork/**)", "Edit(./.deepwork/**)", "Write(./.deepwork/**)", - # All deepwork CLI commands + # All deepwork CLI commands (cross-platform) "Bash(deepwork:*)", - # Job scripts that need to be executable - "Bash(./.deepwork/jobs/deepwork_jobs/make_new_job.sh:*)", ] # NOTE: When modifying required_permissions, update the test assertion in # tests/unit/test_adapters.py::TestClaudeAdapter::test_sync_permissions_idempotent diff --git a/src/deepwork/core/hooks_syncer.py b/src/deepwork/core/hooks_syncer.py index 35a01036..5bd54421 100644 --- a/src/deepwork/core/hooks_syncer.py +++ b/src/deepwork/core/hooks_syncer.py @@ -28,6 +28,10 @@ def get_command(self, project_path: Path) -> str: """ Get the command to run this hook. + This generates a cross-platform command that works on Windows, macOS, and Linux. + For module-based hooks, uses `deepwork hook ` which works everywhere. + For script-based hooks, uses forward slashes (works in bash on all platforms). + Args: project_path: Path to project root @@ -43,10 +47,15 @@ def get_command(self, project_path: Path) -> str: # Script path is: .deepwork/jobs/{job_name}/hooks/{script} script_path = self.job_dir / "hooks" / self.script try: - return str(script_path.relative_to(project_path)) + rel_path = script_path.relative_to(project_path) except ValueError: - # If not relative, return the full path - return str(script_path) + # If not relative, use the full path + rel_path = script_path + + # Always use forward slashes for cross-platform compatibility + # Claude Code runs hooks via bash (even on Windows via Git Bash/WSL) + # and bash expects forward slashes + return str(rel_path).replace("\\", "/") else: raise ValueError("HookEntry must have either script or module") diff --git a/src/deepwork/hooks/__init__.py b/src/deepwork/hooks/__init__.py index 5e9d8d43..81334419 100644 --- a/src/deepwork/hooks/__init__.py +++ b/src/deepwork/hooks/__init__.py @@ -2,41 +2,36 @@ This package provides: -1. Cross-platform hook wrapper system: +1. Cross-platform hook system (Windows, macOS, Linux): - wrapper.py: Normalizes input/output between Claude Code and Gemini CLI - - claude_hook.sh: Shell wrapper for Claude Code hooks - - gemini_hook.sh: Shell wrapper for Gemini CLI hooks + - All hooks use Python modules for cross-platform compatibility 2. Hook implementations: - rules_check.py: Evaluates rules on after_agent events + - user_prompt_submit.py: Captures work tree state on prompt submission + - capture_prompt.py: Git work tree state capture utility -Usage with wrapper system: - # Register hook in .claude/settings.json: +Usage: + # Hooks are registered in .claude/settings.json by `deepwork sync`: { "hooks": { "Stop": [{ "hooks": [{ "type": "command", - "command": ".deepwork/hooks/claude_hook.sh rules_check" + "command": "deepwork hook rules_check" }] - }] - } - } - - # Register hook in .gemini/settings.json: - { - "hooks": { - "AfterAgent": [{ + }], + "UserPromptSubmit": [{ "hooks": [{ "type": "command", - "command": ".gemini/hooks/gemini_hook.sh rules_check" + "command": "deepwork hook user_prompt_submit" }] }] } } -The shell wrappers call `deepwork hook ` which works regardless -of how deepwork was installed (pipx, uv, nix flake, etc.). +The `deepwork hook ` command works on all platforms regardless +of how deepwork was installed (pip, pipx, uv, Windows EXE, etc.). Writing custom hooks: from deepwork.hooks.wrapper import ( diff --git a/src/deepwork/hooks/capture_prompt.py b/src/deepwork/hooks/capture_prompt.py new file mode 100644 index 00000000..1062907c --- /dev/null +++ b/src/deepwork/hooks/capture_prompt.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +capture_prompt.py - Captures the git work tree state at prompt submission. + +This is the cross-platform Python equivalent of capture_prompt_work_tree.sh. + +This script creates a snapshot of ALL tracked files at the time the prompt +is submitted. This baseline is used for rules with compare_to: prompt and +created: mode to detect truly NEW files (not modifications to existing ones). + +The baseline contains ALL tracked files (not just changed files) so that +the rules_check hook can determine which files are genuinely new vs which +files existed before and were just modified. + +It also captures the HEAD commit ref so that committed changes can be detected +by comparing HEAD at Stop time to the captured ref. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def capture_work_tree(project_dir: Path | None = None) -> int: + """ + Capture the current git work tree state. + + Args: + project_dir: Project directory (default: current directory) + + Returns: + 0 on success, non-zero on error + """ + if project_dir is None: + project_dir = Path.cwd() + else: + project_dir = Path(project_dir) + + deepwork_dir = project_dir / ".deepwork" + + # Ensure .deepwork directory exists + deepwork_dir.mkdir(parents=True, exist_ok=True) + + # Save the current HEAD commit ref for detecting committed changes + head_ref_file = deepwork_dir / ".last_head_ref" + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + cwd=str(project_dir), + check=False, + ) + head_ref = result.stdout.strip() if result.returncode == 0 else "" + except Exception: + head_ref = "" + + head_ref_file.write_text(head_ref + "\n", encoding="utf-8") + + # Get all tracked files + tracked_files: set[str] = set() + try: + result = subprocess.run( + ["git", "ls-files"], + capture_output=True, + text=True, + cwd=str(project_dir), + check=False, + ) + if result.returncode == 0: + tracked_files.update(line for line in result.stdout.strip().split("\n") if line) + except Exception: + pass + + # Also include untracked files that exist at prompt time + # These are files the user may have created before submitting the prompt + try: + result = subprocess.run( + ["git", "ls-files", "--others", "--exclude-standard"], + capture_output=True, + text=True, + cwd=str(project_dir), + check=False, + ) + if result.returncode == 0: + tracked_files.update(line for line in result.stdout.strip().split("\n") if line) + except Exception: + pass + + # Sort and write to file + work_tree_file = deepwork_dir / ".last_work_tree" + sorted_files = sorted(tracked_files) + work_tree_file.write_text("\n".join(sorted_files) + "\n" if sorted_files else "", encoding="utf-8") + + return 0 + + +def main() -> int: + """Main entry point for the capture_prompt hook.""" + import json + import os + + # Try to get project directory from environment or stdin + project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("GEMINI_PROJECT_DIR") + + # Also try to read from stdin (hook input JSON) + if not sys.stdin.isatty(): + try: + input_data = json.load(sys.stdin) + if not project_dir: + project_dir = input_data.get("cwd") + except (json.JSONDecodeError, EOFError): + pass + + if project_dir: + return capture_work_tree(Path(project_dir)) + else: + return capture_work_tree() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/deepwork/hooks/hook_entry.py b/src/deepwork/hooks/hook_entry.py new file mode 100644 index 00000000..6cf434c2 --- /dev/null +++ b/src/deepwork/hooks/hook_entry.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +""" +Cross-platform hook entry point for DeepWork. + +This module provides a cross-platform way to invoke DeepWork hooks +that works on Windows, macOS, and Linux without requiring bash. + +Can be invoked directly: + python -m deepwork.hooks.hook_entry rules_check + +Or via the CLI: + deepwork hook rules_check +""" + +from __future__ import annotations + +import sys + + +def main() -> int: + """Main entry point for hook invocation.""" + # Import here to avoid circular imports and speed up module load + from deepwork.cli.hook import hook + + # Get hook name from command line + if len(sys.argv) < 2: + print("Usage: python -m deepwork.hooks.hook_entry ", file=sys.stderr) + print("Example: python -m deepwork.hooks.hook_entry rules_check", file=sys.stderr) + return 1 + + hook_name = sys.argv[1] + + # Click expects sys.argv[0] to be the command name + # We simulate: deepwork hook + sys.argv = ["deepwork", hook_name] + + try: + # Invoke the hook command with standalone_mode=False to get the return value + result = hook.main(args=[hook_name], standalone_mode=False) + return result if isinstance(result, int) else 0 + except SystemExit as e: + return e.code if isinstance(e.code, int) else 0 + except Exception as e: + print(f"Hook execution failed: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/deepwork/hooks/user_prompt_submit.py b/src/deepwork/hooks/user_prompt_submit.py new file mode 100644 index 00000000..dbfb3500 --- /dev/null +++ b/src/deepwork/hooks/user_prompt_submit.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" +user_prompt_submit.py - Runs on every user prompt submission. + +This is the cross-platform Python equivalent of user_prompt_submit.sh. + +This hook captures the work tree state at each prompt submission. +This baseline is used for policies with compare_to: prompt to detect +what changed during an agent response. +""" + +from __future__ import annotations + +import sys + +from deepwork.hooks.capture_prompt import main as capture_prompt_main + + +def main() -> int: + """Main entry point for user_prompt_submit hook.""" + # Capture work tree state at each prompt for compare_to: prompt policies + result = capture_prompt_main() + + # Exit successfully - don't block the prompt + # Even if capture fails, we don't want to block the user + return 0 if result == 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh b/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh deleted file mode 100755 index c9cedd82..00000000 --- a/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# capture_prompt_work_tree.sh - Captures the git work tree state at prompt submission -# -# This script creates a snapshot of ALL tracked files at the time the prompt -# is submitted. This baseline is used for rules with compare_to: prompt and -# created: mode to detect truly NEW files (not modifications to existing ones). -# -# The baseline contains ALL tracked files (not just changed files) so that -# the rules_check hook can determine which files are genuinely new vs which -# files existed before and were just modified. -# -# It also captures the HEAD commit ref so that committed changes can be detected -# by comparing HEAD at Stop time to the captured ref. - -set -e - -# Ensure .deepwork directory exists -mkdir -p .deepwork - -# Save the current HEAD commit ref for detecting committed changes -# This is used by get_changed_files_prompt() to detect files changed since prompt, -# even if those changes were committed during the agent response. -git rev-parse HEAD > .deepwork/.last_head_ref 2>/dev/null || echo "" > .deepwork/.last_head_ref - -# Save ALL tracked files (not just changed files) -# This is critical for created: mode rules to distinguish between: -# - Newly created files (not in baseline) -> should trigger created: rules -# - Modified existing files (in baseline) -> should NOT trigger created: rules -git ls-files > .deepwork/.last_work_tree 2>/dev/null || true - -# Also include untracked files that exist at prompt time -# These are files the user may have created before submitting the prompt -git ls-files --others --exclude-standard >> .deepwork/.last_work_tree 2>/dev/null || true - -# Sort and deduplicate -if [ -f .deepwork/.last_work_tree ]; then - sort -u .deepwork/.last_work_tree -o .deepwork/.last_work_tree -fi diff --git a/src/deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml b/src/deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml index a310d31a..ee280632 100644 --- a/src/deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml +++ b/src/deepwork/standard_jobs/deepwork_rules/hooks/global_hooks.yml @@ -1,8 +1,11 @@ # DeepWork Rules Hooks Configuration # Maps lifecycle events to hook scripts or Python modules +# +# All hooks use Python modules for cross-platform compatibility (Windows, macOS, Linux). +# The module syntax ensures hooks work regardless of how DeepWork was installed. UserPromptSubmit: - - user_prompt_submit.sh + - module: deepwork.hooks.user_prompt_submit Stop: - module: deepwork.hooks.rules_check diff --git a/src/deepwork/standard_jobs/deepwork_rules/hooks/user_prompt_submit.sh b/src/deepwork/standard_jobs/deepwork_rules/hooks/user_prompt_submit.sh deleted file mode 100755 index 486ad836..00000000 --- a/src/deepwork/standard_jobs/deepwork_rules/hooks/user_prompt_submit.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# user_prompt_submit.sh - Runs on every user prompt submission -# -# This script captures the work tree state at each prompt submission. -# This baseline is used for policies with compare_to: prompt to detect -# what changed during an agent response. - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Capture work tree state at each prompt for compare_to: prompt policies -"${SCRIPT_DIR}/capture_prompt_work_tree.sh" - -# Exit successfully - don't block the prompt -exit 0 diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py index de22f217..7852bed9 100644 --- a/tests/unit/test_adapters.py +++ b/tests/unit/test_adapters.py @@ -195,8 +195,8 @@ def test_sync_permissions_creates_settings_file(self, temp_dir: Path) -> None: count = adapter.sync_permissions(temp_dir) assert ( - count == 5 - ) # Read, Edit, Write for .deepwork/** + Bash for deepwork CLI + Bash for make_new_job.sh + count == 4 + ) # Read, Edit, Write for .deepwork/** + Bash for deepwork CLI settings_file = temp_dir / ".claude" / "settings.json" assert settings_file.exists() settings = json.loads(settings_file.read_text()) @@ -229,8 +229,8 @@ def test_sync_permissions_idempotent(self, temp_dir: Path) -> None: # First call adds permissions count1 = adapter.sync_permissions(temp_dir) assert ( - count1 == 5 - ) # Read, Edit, Write for .deepwork/** + Bash for deepwork CLI + Bash for make_new_job.sh + count1 == 4 + ) # Read, Edit, Write for .deepwork/** + Bash for deepwork CLI # Second call should add nothing count2 = adapter.sync_permissions(temp_dir) diff --git a/tests/unit/test_cross_platform_hooks.py b/tests/unit/test_cross_platform_hooks.py new file mode 100644 index 00000000..4f8b879c --- /dev/null +++ b/tests/unit/test_cross_platform_hooks.py @@ -0,0 +1,119 @@ +"""Tests for cross-platform hook implementations.""" + +import json +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + + +class TestCapturePromptHook: + """Tests for the capture_prompt hook.""" + + def test_capture_work_tree(self, tmp_path: Path) -> None: + """Test capture_work_tree creates expected files.""" + from deepwork.hooks.capture_prompt import capture_work_tree + + # Initialize a git repo with signing disabled + subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + # Disable commit signing for this test + subprocess.run( + ["git", "config", "commit.gpgsign", "false"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + + # Create a tracked file + (tmp_path / "test.txt").write_text("hello") + subprocess.run(["git", "add", "test.txt"], cwd=tmp_path, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "initial", "--no-gpg-sign"], + cwd=tmp_path, + capture_output=True, + check=True, + ) + + # Run capture + result = capture_work_tree(tmp_path) + + # Verify files were created + assert result == 0 + assert (tmp_path / ".deepwork" / ".last_head_ref").exists() + assert (tmp_path / ".deepwork" / ".last_work_tree").exists() + + # Verify content + work_tree = (tmp_path / ".deepwork" / ".last_work_tree").read_text() + assert "test.txt" in work_tree + + +class TestUserPromptSubmitHook: + """Tests for the user_prompt_submit hook.""" + + def test_main_returns_zero(self, tmp_path: Path) -> None: + """Test main returns 0 even if capture fails.""" + from deepwork.hooks.user_prompt_submit import main + + # Set up environment to use tmp_path + with patch.dict("os.environ", {"CLAUDE_PROJECT_DIR": str(tmp_path)}): + # Provide empty JSON input + with patch("sys.stdin.isatty", return_value=True): + result = main() + + # Should succeed (returns 0 even on errors to not block prompt) + assert result == 0 + + +class TestHookCommandFormat: + """Tests for hook command generation.""" + + def test_module_hook_command_is_cross_platform(self, tmp_path: Path) -> None: + """Test that module hooks generate cross-platform commands.""" + from deepwork.core.hooks_syncer import HookEntry + + entry = HookEntry( + job_name="test_job", + job_dir=tmp_path / ".deepwork" / "jobs" / "test_job", + module="deepwork.hooks.rules_check", + ) + + command = entry.get_command(tmp_path) + + # Should use deepwork CLI, not bash scripts + assert command == "deepwork hook rules_check" + assert ".sh" not in command + + def test_script_hook_uses_forward_slashes(self, tmp_path: Path) -> None: + """Test that script hooks use forward slashes for cross-platform compatibility.""" + from deepwork.core.hooks_syncer import HookEntry + + job_dir = tmp_path / ".deepwork" / "jobs" / "test_job" + job_dir.mkdir(parents=True) + (job_dir / "hooks").mkdir() + (job_dir / "hooks" / "test.sh").write_text("#!/bin/bash\nexit 0") + + entry = HookEntry( + job_name="test_job", + job_dir=job_dir, + script="test.sh", + ) + + command = entry.get_command(tmp_path) + + # Should use forward slashes, even on Windows-style paths + assert "\\" not in command + assert "/" in command