diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 77cd29c2b2..e0bc0272ce 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -15,7 +15,7 @@ on: operating-systems: type: string required: false - default: '["ubuntu-latest","macos-latest"]' + default: '["ubuntu-latest","macos-latest","windows-latest"]' architectures: type: string required: false @@ -37,6 +37,8 @@ jobs: target-suffix: unknown-linux-gnu - os: macos-latest target-suffix: apple-darwin + - os: windows-latest + target-suffix: pc-windows-gnu steps: - name: Checkout code @@ -60,12 +62,20 @@ jobs: - name: Build CLI env: CROSS_NO_WARNINGS: 0 + RUST_LOG: debug + RUST_BACKTRACE: 1 + CROSS_VERBOSE: 1 run: | export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" rustup target add "${TARGET}" + echo "Building for target: ${TARGET}" + echo "Rust toolchain info:" + rustup show + echo "Cross version:" + cross --version # 'cross' is used to cross-compile for different architectures (see Cross.toml) - cross build --release --target ${TARGET} -p goose-cli + cross build --release --target ${TARGET} -p goose-cli -vv # tar the goose binary as goose-.tar.bz2 cd target/${TARGET}/release diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml new file mode 100644 index 0000000000..856c59c35d --- /dev/null +++ b/.github/workflows/bundle-desktop-windows.yml @@ -0,0 +1,157 @@ +name: "Bundle Desktop (Windows)" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_call: + inputs: + signing: + description: 'Whether to sign the Windows executable' + required: false + type: boolean + default: false + secrets: + WINDOWS_CERTIFICATE: + required: false + WINDOWS_CERTIFICATE_PASSWORD: + required: false + +jobs: + build-desktop-windows: + name: Build Desktop (Windows) + runs-on: windows-latest + + steps: + # 1) Check out source + - name: Checkout repository + uses: actions/checkout@v3 + + # 2) Set up Rust + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + # If you need a specific version, you could do: + # or uses: actions/setup-rust@v1 + # with: + # rust-version: 1.73.0 + + # 3) Set up Node.js + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 16 + + # 4) Cache dependencies (optional, can add more paths if needed) + - name: Cache node_modules + uses: actions/cache@v3 + with: + path: | + node_modules + ui/desktop/node_modules + key: ${{ runner.os }}-build-desktop-windows-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-desktop-windows- + + # 5) Install top-level dependencies if a package.json is in root + - name: Install top-level deps + run: | + if (Test-Path package.json) { + npm install + } + + # 6) Build rust for x86_64-pc-windows-gnu + - name: Install MinGW dependencies + run: | + choco install mingw --version=8.1.0 + # Debug - check installation paths + Write-Host "Checking MinGW installation..." + Get-ChildItem -Path "C:\ProgramData\chocolatey\lib\mingw" -Recurse -Filter "*.dll" | ForEach-Object { + Write-Host $_.FullName + } + Get-ChildItem -Path "C:\tools" -Recurse -Filter "*.dll" | ForEach-Object { + Write-Host $_.FullName + } + + - name: Cargo build for Windows + run: | + cargo build --release --target x86_64-pc-windows-gnu + + # 7) Check that the compiled goosed.exe exists and copy exe/dll to ui/desktop/src/bin + - name: Prepare Windows binary and DLLs + run: | + if (!(Test-Path .\target\x86_64-pc-windows-gnu\release\goosed.exe)) { + Write-Error "Windows binary not found."; exit 1; + } + Write-Host "Copying Windows binary and DLLs to ui/desktop/src/bin..." + if (!(Test-Path ui\desktop\src\bin)) { + New-Item -ItemType Directory -Path ui\desktop\src\bin | Out-Null + } + Copy-Item .\target\x86_64-pc-windows-gnu\release\goosed.exe ui\desktop\src\bin\ + + # Copy MinGW DLLs - try both possible locations + $mingwPaths = @( + "C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin", + "C:\tools\mingw64\bin" + ) + + foreach ($path in $mingwPaths) { + if (Test-Path "$path\libstdc++-6.dll") { + Write-Host "Found MinGW DLLs in $path" + Copy-Item "$path\libstdc++-6.dll" ui\desktop\src\bin\ + Copy-Item "$path\libgcc_s_seh-1.dll" ui\desktop\src\bin\ + Copy-Item "$path\libwinpthread-1.dll" ui\desktop\src\bin\ + break + } + } + + # Copy any other DLLs from the release directory + ls .\target\x86_64-pc-windows-gnu\release\*.dll | ForEach-Object { + Copy-Item $_ ui\desktop\src\bin\ + } + + # 8) Install & build UI desktop + - name: Build desktop UI with npm + run: | + cd ui\desktop + npm install + npm run bundle:windows + + # 9) Copy exe/dll to final out/Goose-win32-x64/resources/bin + - name: Copy exe/dll to out folder + run: | + cd ui\desktop + if (!(Test-Path .\out\Goose-win32-x64\resources\bin)) { + New-Item -ItemType Directory -Path .\out\Goose-win32-x64\resources\bin | Out-Null + } + Copy-Item .\src\bin\goosed.exe .\out\Goose-win32-x64\resources\bin\ + ls .\src\bin\*.dll | ForEach-Object { + Copy-Item $_ .\out\Goose-win32-x64\resources\bin\ + } + + # 10) Code signing (if enabled) + - name: Sign Windows executable + # Skip this step by default - enable when we have a certificate + if: inputs.signing && inputs.signing == true + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + # Create a temporary certificate file + $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) + $certPath = Join-Path -Path $env:RUNNER_TEMP -ChildPath "certificate.pfx" + [IO.File]::WriteAllBytes($certPath, $certBytes) + + # Sign the main executable + $signtool = "C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64\signtool.exe" + & $signtool sign /f $certPath /p $env:WINDOWS_CERTIFICATE_PASSWORD /tr http://timestamp.digicert.com /td sha256 /fd sha256 "ui\desktop\out\Goose-win32-x64\Goose.exe" + + # Clean up the certificate + Remove-Item -Path $certPath + + # 11) Upload the final Windows build + - name: Upload Windows build artifacts + uses: actions/upload-artifact@v4 + with: + name: desktop-windows-dist + path: ui/desktop/out/Goose-win32-x64/ diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index 18b23401d3..235da57164 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -201,21 +201,5 @@ jobs: echo "App did not stay open. Possible crash or startup error." exit 1 fi - LOGFILE="$HOME/Library/Application Support/Goose/logs/main.log" - # Print the log and verify "ChatWindow loaded" is in the logs - if [ -f "$LOGFILE" ]; then - echo "===== Log file contents =====" - cat "$LOGFILE" - echo "=============================" - if grep -F "ChatWindow loaded" "$LOGFILE"; then - echo "Confirmed: 'ChatWindow loaded' found in logs!" - else - echo "Did not find 'ChatWindow loaded' in logs. Failing..." - exit 1 - fi - else - echo "No log file found at $LOGFILE. Exiting with failure." - exit 1 - fi # Kill the app to clean up - pkill -f "Goose.app/Contents/MacOS/Goose" \ No newline at end of file + pkill -f "Goose.app/Contents/MacOS/Goose" diff --git a/.github/workflows/pr-comment-bundle-desktop.yml b/.github/workflows/pr-comment-bundle-desktop.yml index dd5de57057..32b3003709 100644 --- a/.github/workflows/pr-comment-bundle-desktop.yml +++ b/.github/workflows/pr-comment-bundle-desktop.yml @@ -51,10 +51,21 @@ jobs: APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + bundle-desktop-windows: + # Only run this if ".bundle" command is detected. + needs: [trigger-on-command] + if: ${{ needs.trigger-on-command.outputs.continue == 'true' }} + uses: ./.github/workflows/bundle-desktop-windows.yml + with: + signing: true + secrets: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + pr-comment: name: PR Comment with Desktop App runs-on: ubuntu-latest - needs: [trigger-on-command, bundle-desktop] + needs: [trigger-on-command, bundle-desktop, bundle-desktop-windows] permissions: pull-requests: write @@ -71,10 +82,15 @@ jobs: body: | ### Desktop App for this PR - The following build is available for testing: + The following builds are available for testing: - [📱 macOS Desktop App (arm64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/Goose-darwin-arm64.zip) + - [🪟 Windows Desktop App (x64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/desktop-windows-dist.zip) + **macOS Instructions:** After downloading, unzip the file and drag the Goose.app to your Applications folder. The app is signed and notarized for macOS. - This link is provided by nightly.link and will work even if you're not logged into GitHub. + **Windows Instructions:** + After downloading, unzip the file and run Goose.exe. The app is signed for Windows. + + These links are provided by nightly.link and will work even if you're not logged into GitHub. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d97c7a36c..0eb1b4e3a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: path: download_cli.sh # ------------------------------------------------------------ - # 3) Bundle Desktop App (macOS only) + # 3) Bundle Desktop App (macOS) # ------------------------------------------------------------ bundle-desktop: uses: ./.github/workflows/bundle-desktop.yml @@ -46,6 +46,19 @@ jobs: APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} +# # ------------------------------------------------------------ +# # 4) Bundle Desktop App (Windows) +# # ------------------------------------------------------------ +# bundle-desktop-windows: +# uses: ./.github/workflows/bundle-desktop-windows.yml +# # Signing is disabled by default until we have a certificate +# with: +# signing: false +# # Uncomment and configure these when we have a certificate: +# # secrets: +# # WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} +# # WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + # ------------------------------------ # 4) Create/Update GitHub Release # ------------------------------------ diff --git a/Cross.toml b/Cross.toml index 3469859802..fc461fe152 100644 --- a/Cross.toml +++ b/Cross.toml @@ -12,7 +12,6 @@ pre-build = [ libxcb1-dev:arm64 """ ] -env = { PKG_CONFIG_PATH = "/usr/lib/aarch64-linux-gnu/pkgconfig" } [target.x86_64-unknown-linux-gnu] xargo = false @@ -27,3 +26,9 @@ pre-build = [ libxcb1-dev \ """ ] + +[target.x86_64-pc-windows-gnu] +image = "dockcross/windows-static-x64:latest" +# Enable verbose output for Windows builds +build-std = true +env = { "RUST_LOG" = "debug", "RUST_BACKTRACE" = "1", "CROSS_VERBOSE" = "1" } diff --git a/Justfile b/Justfile index 1f3495ae27..257c3191e7 100644 --- a/Justfile +++ b/Justfile @@ -10,6 +10,31 @@ release-binary: cargo build --release @just copy-binary +# Build Windows executable +release-windows: + #!/usr/bin/env sh + if [ "$(uname)" = "Darwin" ] || [ "$(uname)" = "Linux" ]; then + echo "Building Windows executable using Docker..." + docker volume create goose-windows-cache || true + docker run --rm \ + -v "$(pwd)":/usr/src/myapp \ + -v goose-windows-cache:/usr/local/cargo/registry \ + -w /usr/src/myapp \ + rust:latest \ + sh -c "rustup target add x86_64-pc-windows-gnu && \ + apt-get update && \ + apt-get install -y mingw-w64 && \ + cargo build --release --target x86_64-pc-windows-gnu && \ + GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) && \ + cp \$GCC_DIR/libstdc++-6.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \ + cp \$GCC_DIR/libgcc_s_seh-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \ + cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/" + else + echo "Building Windows executable using Docker through PowerShell..." + powershell.exe -Command "docker volume create goose-windows-cache; docker run --rm -v ${PWD}:/usr/src/myapp -v goose-windows-cache:/usr/local/cargo/registry -w /usr/src/myapp rust:latest sh -c 'rustup target add x86_64-pc-windows-gnu && apt-get update && apt-get install -y mingw-w64 && cargo build --release --target x86_64-pc-windows-gnu && GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) && cp \$GCC_DIR/libstdc++-6.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && cp \$GCC_DIR/libgcc_s_seh-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/'" + fi + echo "Windows executable and required DLLs created at ./target/x86_64-pc-windows-gnu/release/" + # Copy binary command copy-binary: @if [ -f ./target/release/goosed ]; then \ @@ -20,12 +45,30 @@ copy-binary: exit 1; \ fi +# Copy Windows binary command +copy-binary-windows: + @powershell.exe -Command "if (Test-Path ./target/x86_64-pc-windows-gnu/release/goosed.exe) { \ + Write-Host 'Copying Windows binary and DLLs to ui/desktop/src/bin...'; \ + Copy-Item -Path './target/x86_64-pc-windows-gnu/release/goosed.exe' -Destination './ui/desktop/src/bin/' -Force; \ + Copy-Item -Path './target/x86_64-pc-windows-gnu/release/*.dll' -Destination './ui/desktop/src/bin/' -Force; \ + } else { \ + Write-Host 'Windows binary not found.' -ForegroundColor Red; \ + exit 1; \ + }" + # Run UI with latest run-ui: @just release-binary @echo "Running UI..." cd ui/desktop && npm install && npm run start-gui +# Run UI with latest (Windows version) +run-ui-windows: + @just release-windows + @powershell.exe -Command "Write-Host 'Copying Windows binary...'" + @just copy-binary-windows + @powershell.exe -Command "Write-Host 'Running UI...'; Set-Location ui/desktop; npm install; npm run start-gui" + # Run Docusaurus server for documentation run-docs: @echo "Running docs server..." @@ -41,6 +84,26 @@ make-ui: @just release-binary cd ui/desktop && npm run bundle:default +# make GUI with latest Windows binary +make-ui-windows: + @just release-windows + #!/usr/bin/env sh + if [ -f "./target/x86_64-pc-windows-gnu/release/goosed.exe" ]; then \ + echo "Copying Windows binary and DLLs to ui/desktop/src/bin..."; \ + mkdir -p ./ui/desktop/src/bin; \ + cp -f ./target/x86_64-pc-windows-gnu/release/goosed.exe ./ui/desktop/src/bin/; \ + cp -f ./target/x86_64-pc-windows-gnu/release/*.dll ./ui/desktop/src/bin/; \ + echo "Building Windows package..."; \ + cd ui/desktop && \ + npm run bundle:windows && \ + mkdir -p out/Goose-win32-x64/resources/bin && \ + cp -f src/bin/goosed.exe out/Goose-win32-x64/resources/bin/ && \ + cp -f src/bin/*.dll out/Goose-win32-x64/resources/bin/; \ + else \ + echo "Windows binary not found."; \ + exit 1; \ + fi + # Setup langfuse server langfuse-server: #!/usr/bin/env bash diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index db5b34c510..77a1df05e9 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -46,6 +46,10 @@ tracing = "0.1" chrono = "0.4" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] } tracing-appender = "0.2" +winapi = { version = "0.3", features = ["wincred"], optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +winapi = { version = "0.3", features = ["wincred"] } [dev-dependencies] tempfile = "3" diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 67e3807ec7..a998a50e95 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -62,12 +62,29 @@ pub async fn handle_configure() -> Result<(), Box> { ); } Some(ConfigError::KeyringError(msg)) => { + #[cfg(target_os = "macos")] println!( "\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.", style("Error").red().italic(), msg, style("goose configure").cyan() ); + + #[cfg(target_os = "windows")] + println!( + "\n {} Failed to access Windows Credential Manager: {} \n Please check Windows Credential Manager and run '{}' again. \n If your system is unable to use the Credential Manager, please try setting secret key(s) via environment variables.", + style("Error").red().italic(), + msg, + style("goose configure").cyan() + ); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + println!( + "\n {} Failed to access secure storage: {} \n Please check your system's secure storage and run '{}' again. \n If your system is unable to use secure storage, please try setting secret key(s) via environment variables.", + style("Error").red().italic(), + msg, + style("goose configure").cyan() + ); } Some(ConfigError::DeserializeError(msg)) => { println!( diff --git a/crates/goose-cli/src/logging.rs b/crates/goose-cli/src/logging.rs index f7ebc53477..d63531630c 100644 --- a/crates/goose-cli/src/logging.rs +++ b/crates/goose-cli/src/logging.rs @@ -12,7 +12,12 @@ use goose::tracing::langfuse_layer; /// Returns the directory where log files should be stored. /// Creates the directory structure if it doesn't exist. fn get_log_directory() -> Result { - let home = std::env::var("HOME").context("HOME environment variable not set")?; + let home = if cfg!(windows) { + std::env::var("USERPROFILE").context("USERPROFILE environment variable not set")? + } else { + std::env::var("HOME").context("HOME environment variable not set")? + }; + let base_log_dir = PathBuf::from(home) .join(".config") .join("goose") @@ -114,7 +119,11 @@ mod tests { fn setup_temp_home() -> TempDir { let temp_dir = TempDir::new().unwrap(); - env::set_var("HOME", temp_dir.path()); + if cfg!(windows) { + env::set_var("USERPROFILE", temp_dir.path()); + } else { + env::set_var("HOME", temp_dir.path()); + } temp_dir } diff --git a/crates/goose-mcp/src/computercontroller/mod.rs b/crates/goose-mcp/src/computercontroller/mod.rs index 4429ce76e8..b6885f91ad 100644 --- a/crates/goose-mcp/src/computercontroller/mod.rs +++ b/crates/goose-mcp/src/computercontroller/mod.rs @@ -3,8 +3,7 @@ use indoc::{formatdoc, indoc}; use reqwest::{Client, Url}; use serde_json::{json, Value}; use std::{ - collections::HashMap, fs, future::Future, os::unix::fs::PermissionsExt, path::PathBuf, - pin::Pin, sync::Arc, sync::Mutex, + collections::HashMap, fs, future::Future, path::PathBuf, pin::Pin, sync::Arc, sync::Mutex, }; use tokio::process::Command; @@ -18,6 +17,9 @@ use mcp_core::{ use mcp_server::router::CapabilitiesBuilder; use mcp_server::Router; +mod platform; +use platform::{create_system_automation, SystemAutomation}; + /// An extension designed for non-developers to help them with common tasks like /// web scraping, data processing, and automation. #[derive(Clone)] @@ -27,6 +29,7 @@ pub struct ComputerControllerRouter { active_resources: Arc>>, http_client: Client, instructions: String, + system_automation: Arc>, } impl Default for ComputerControllerRouter { @@ -86,9 +89,19 @@ impl ComputerControllerRouter { }), ); - let computer_control_tool = Tool::new( - "computer_control", - indoc! {r#" + let computer_control_desc = match std::env::consts::OS { + "windows" => indoc! {r#" + Control the computer using Windows system automation. + + Features available: + - PowerShell automation for system control + - UI automation through PowerShell + - File and system management + - Windows-specific features and settings + + Can be combined with screenshot tool for visual task assistance. + "#}, + _ => indoc! {r#" Control the computer using AppleScript (macOS only). Automate applications and system features. Key capabilities: @@ -104,14 +117,19 @@ impl ComputerControllerRouter { - Data: Interact with spreadsheets and documents Can be combined with screenshot tool for visual task assistance. - "#}, + "#}, + }; + + let computer_control_tool = Tool::new( + "computer_control", + computer_control_desc.to_string(), json!({ "type": "object", "required": ["script"], "properties": { "script": { "type": "string", - "description": "The AppleScript content to execute" + "description": "The automation script content (PowerShell for Windows, AppleScript for macOS)" }, "save_output": { "type": "boolean", @@ -122,9 +140,18 @@ impl ComputerControllerRouter { }), ); - let quick_script_tool = Tool::new( - "automation_script", - indoc! {r#" + let quick_script_desc = match std::env::consts::OS { + "windows" => indoc! {r#" + Create and run small PowerShell or Batch scripts for automation tasks. + PowerShell is recommended for most tasks. + + The script is saved to a temporary file and executed. + Some examples: + - Sort unique lines: Get-Content file.txt | Sort-Object -Unique + - Extract CSV column: Import-Csv file.csv | Select-Object -ExpandProperty Column2 + - Find text: Select-String -Pattern "pattern" -Path file.txt + "#}, + _ => indoc! {r#" Create and run small scripts for automation tasks. Supports Shell and Ruby (on macOS). @@ -135,14 +162,19 @@ impl ComputerControllerRouter { - create a sorted list of unique lines: sort file.txt | uniq - extract 2nd column in csv: awk -F "," '{ print $2}' - pattern matching: grep pattern file.txt - "#}, + "#}, + }; + + let quick_script_tool = Tool::new( + "automation_script", + quick_script_desc.to_string(), json!({ "type": "object", "required": ["language", "script"], "properties": { "language": { "type": "string", - "enum": ["shell", "ruby"], + "enum": ["shell", "ruby", "powershell", "batch"], "description": "The scripting language to use" }, "script": { @@ -186,9 +218,10 @@ impl ComputerControllerRouter { // Create cache directory in user's home directory let cache_dir = dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) + .unwrap_or_else(|| create_system_automation().get_temp_path()) .join("goose") .join("computer_controller"); + fs::create_dir_all(&cache_dir).unwrap_or_else(|_| { println!( "Warning: Failed to create cache directory at {:?}", @@ -196,8 +229,41 @@ impl ComputerControllerRouter { ) }); - let macos_browser_instructions = if std::env::consts::OS == "macos" { - indoc! {r#" + let system_automation: Arc> = + Arc::new(create_system_automation()); + + let os_specific_instructions = match std::env::consts::OS { + "windows" => indoc! {r#" + Here are some extra tools: + automation_script + - Create and run PowerShell or Batch scripts + - PowerShell is recommended for most tasks + - Scripts can save their output to files + - Windows-specific features: + - PowerShell for system automation and UI control + - Windows Management Instrumentation (WMI) + - Registry access and system settings + - Use the screenshot tool if needed to help with tasks + + computer_control + - System automation using PowerShell + - Consider the screenshot tool to work out what is on screen and what to do to help with the control task. + "#}, + _ => indoc! {r#" + Here are some extra tools: + automation_script + - Create and run Shell and Ruby scripts + - Shell (bash) is recommended for most tasks + - Scripts can save their output to files + - macOS-specific features: + - AppleScript for system and UI control + - Integration with macOS apps and services + - Use the screenshot tool if needed to help with tasks + + computer_control + - System automation using AppleScript + - Consider the screenshot tool to work out what is on screen and what to do to help with the control task. + When you need to interact with websites or web applications, consider using the computer_control tool with AppleScript, which can automate Safari or other browsers to: - Open specific URLs - Fill in forms @@ -205,47 +271,25 @@ impl ComputerControllerRouter { - Extract content - Handle web-based workflows This is often more reliable than web scraping for modern web applications. - "#} - } else { - "" + "#}, }; let instructions = formatdoc! {r#" - You are a helpful assistant to a power user who is not a professional developer, but you may use devleopment tools to help assist them. + You are a helpful assistant to a power user who is not a professional developer, but you may use development tools to help assist them. The user may not know how to break down tasks, so you will need to ensure that you do, and run things in batches as needed. The ComputerControllerExtension helps you with common tasks like web scraping, - data processing, and automation and computer control without requiring programming expertise, - supplementing the Developer Extension. + data processing, and automation without requiring programming expertise. You can use scripting as needed to work with text files of data, such as csvs, json, or text files etc. Using the developer extension is allowed for more sophisticated tasks or instructed to (js or py can be helpful for more complex tasks if tools are available). - {macos_instructions} - - Accessing web sites, even apis, may be common (you can use bash scripting to do this) without troubling them too much (they won't know what limits are). - Try to do your best to find ways to complete a task without too many quesitons or offering options unless it is really unclear, find a way if you can. + Accessing web sites, even apis, may be common (you can use scripting to do this) without troubling them too much (they won't know what limits are). + Try to do your best to find ways to complete a task without too many questions or offering options unless it is really unclear, find a way if you can. You can also guide them steps if they can help out as you go along. There is already a screenshot tool available you can use if needed to see what is on screen. - Here are some extra tools: - automation_script - - Create and run simple automation scripts - - Supports Shell (such as bash), AppleScript (on macos), Ruby (on macos) - - Scripts can save their output to files - - on macos, can use applescript to interact with the desktop, eg calendars, notes and more, anything apple script can do for apps that support it: - AppleScript is a powerful scripting language designed for automating tasks on macOS such as: Integration with Other Scripts - Execute shell scripts, Ruby scripts, or other automation scripts. - Combine workflows across scripting languages. - Complex Workflows - Automate multi-step tasks involving multiple apps or system features. - Create scheduled tasks using Calendar or other scheduling apps. - - - use the screenshot tool if needed to help with tasks - - computer_control - - Control the computer using AppleScript (macOS only) - - Consider the screenshot tool to work out what is on screen and what to do to help with the control task. + {os_instructions} web_search - Search the web using DuckDuckGo's API for general topics or keywords @@ -262,7 +306,7 @@ impl ComputerControllerRouter { - Cache directory: {cache_dir} - File organization and cleanup "#, - macos_instructions = macos_browser_instructions, + os_instructions = os_specific_instructions, cache_dir = cache_dir.display() }; @@ -278,6 +322,7 @@ impl ComputerControllerRouter { active_resources: Arc::new(Mutex::new(HashMap::new())), http_client: Client::builder().user_agent("Goose/1.0").build().unwrap(), instructions: instructions.clone(), + system_automation, } } @@ -318,7 +363,7 @@ impl ComputerControllerRouter { Ok(()) } - // Implement web_scrape tool functionality + // Implement web_search tool functionality async fn web_search(&self, params: Value) -> Result, ToolError> { let query = params .get("query") @@ -452,22 +497,18 @@ impl ComputerControllerRouter { ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e)) })?; + let (shell, shell_arg) = self.system_automation.get_shell_command(); + let command = match language { - "shell" => { - let script_path = script_dir.path().join("script.sh"); + "shell" | "batch" => { + let script_path = script_dir.path().join(format!( + "script.{}", + if cfg!(windows) { "bat" } else { "sh" } + )); fs::write(&script_path, script).map_err(|e| { ToolError::ExecutionError(format!("Failed to write script: {}", e)) })?; - fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).map_err( - |e| { - ToolError::ExecutionError(format!( - "Failed to set script permissions: {}", - e - )) - }, - )?; - script_path.display().to_string() } "ruby" => { @@ -478,12 +519,23 @@ impl ComputerControllerRouter { format!("ruby {}", script_path.display()) } + "powershell" => { + let script_path = script_dir.path().join("script.ps1"); + fs::write(&script_path, script).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write script: {}", e)) + })?; + + format!( + "powershell -NoProfile -NonInteractive -File {}", + script_path.display() + ) + } _ => unreachable!(), // Prevented by enum in tool definition }; // Run the script - let output = Command::new("bash") - .arg("-c") + let output = Command::new(shell) + .arg(shell_arg) .arg(&command) .output() .await @@ -515,14 +567,8 @@ impl ComputerControllerRouter { Ok(vec![Content::text(result)]) } - // Implement computer control (AppleScript) functionality + // Implement computer control functionality async fn computer_control(&self, params: Value) -> Result, ToolError> { - if std::env::consts::OS != "macos" { - return Err(ToolError::ExecutionError( - "Computer control (AppleScript) is only supported on macOS".into(), - )); - } - let script = params .get("script") .and_then(|v| v.as_str()) @@ -533,44 +579,18 @@ impl ComputerControllerRouter { .and_then(|v| v.as_bool()) .unwrap_or(false); - // Create a temporary directory for the script - let script_dir = tempfile::tempdir().map_err(|e| { - ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e)) - })?; + // Use platform-specific automation + let output = self + .system_automation + .execute_system_script(script) + .map_err(|e| ToolError::ExecutionError(format!("Failed to execute script: {}", e)))?; - let script_path = script_dir.path().join("script.scpt"); - fs::write(&script_path, script) - .map_err(|e| ToolError::ExecutionError(format!("Failed to write script: {}", e)))?; - - let command = format!("osascript {}", script_path.display()); - - // Run the script - let output = Command::new("bash") - .arg("-c") - .arg(&command) - .output() - .await - .map_err(|e| ToolError::ExecutionError(format!("Failed to run AppleScript: {}", e)))?; - - let output_str = String::from_utf8_lossy(&output.stdout).into_owned(); - let error_str = String::from_utf8_lossy(&output.stderr).into_owned(); - - let mut result = if output.status.success() { - format!( - "AppleScript completed successfully.\n\nOutput:\n{}", - output_str - ) - } else { - format!( - "AppleScript failed with error code {}.\n\nError:\n{}\nOutput:\n{}", - output.status, error_str, output_str - ) - }; + let mut result = format!("Script completed successfully.\n\nOutput:\n{}", output); // Save output if requested - if save_output && !output_str.is_empty() { + if save_output && !output.is_empty() { let cache_path = self - .save_to_cache(output_str.as_bytes(), "applescript_output", "txt") + .save_to_cache(output.as_bytes(), "automation_output", "txt") .await?; result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display())); diff --git a/crates/goose-mcp/src/computercontroller/platform/macos.rs b/crates/goose-mcp/src/computercontroller/platform/macos.rs new file mode 100644 index 0000000000..09c16567d6 --- /dev/null +++ b/crates/goose-mcp/src/computercontroller/platform/macos.rs @@ -0,0 +1,25 @@ +use super::SystemAutomation; +use std::path::PathBuf; +use std::process::Command; + +pub struct MacOSAutomation; + +// MacOSAutomation is Send + Sync because it contains no shared state +unsafe impl Send for MacOSAutomation {} +unsafe impl Sync for MacOSAutomation {} + +impl SystemAutomation for MacOSAutomation { + fn execute_system_script(&self, script: &str) -> std::io::Result { + let output = Command::new("osascript").arg("-e").arg(script).output()?; + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + fn get_shell_command(&self) -> (&'static str, &'static str) { + ("bash", "-c") + } + + fn get_temp_path(&self) -> PathBuf { + PathBuf::from("/tmp") + } +} diff --git a/crates/goose-mcp/src/computercontroller/platform/mod.rs b/crates/goose-mcp/src/computercontroller/platform/mod.rs new file mode 100644 index 0000000000..3b3219d370 --- /dev/null +++ b/crates/goose-mcp/src/computercontroller/platform/mod.rs @@ -0,0 +1,29 @@ +mod macos; +mod windows; + +#[cfg(target_os = "windows")] +pub use self::windows::WindowsAutomation; + +#[cfg(target_os = "macos")] +pub use self::macos::MacOSAutomation; + +pub trait SystemAutomation: Send + Sync { + fn execute_system_script(&self, script: &str) -> std::io::Result; + fn get_shell_command(&self) -> (&'static str, &'static str); // (shell, arg) + fn get_temp_path(&self) -> std::path::PathBuf; +} + +pub fn create_system_automation() -> Box { + #[cfg(target_os = "windows")] + { + Box::new(WindowsAutomation) + } + #[cfg(target_os = "macos")] + { + Box::new(MacOSAutomation) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + unimplemented!("Unsupported operating system") + } +} diff --git a/crates/goose-mcp/src/computercontroller/platform/windows.rs b/crates/goose-mcp/src/computercontroller/platform/windows.rs new file mode 100644 index 0000000000..4e7c9e920d --- /dev/null +++ b/crates/goose-mcp/src/computercontroller/platform/windows.rs @@ -0,0 +1,32 @@ +use super::SystemAutomation; +use std::path::PathBuf; +use std::process::Command; + +pub struct WindowsAutomation; + +// WindowsAutomation is Send + Sync because it contains no shared state +unsafe impl Send for WindowsAutomation {} +unsafe impl Sync for WindowsAutomation {} + +impl SystemAutomation for WindowsAutomation { + fn execute_system_script(&self, script: &str) -> std::io::Result { + let output = Command::new("powershell") + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(script) + .output()?; + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + fn get_shell_command(&self) -> (&'static str, &'static str) { + ("powershell", "-Command") + } + + fn get_temp_path(&self) -> PathBuf { + std::env::var("TEMP") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(r"C:\Windows\Temp")) + } +} diff --git a/crates/goose-mcp/src/developer/lang.rs b/crates/goose-mcp/src/developer/lang.rs index 4d5609de25..27f17cd7f6 100644 --- a/crates/goose-mcp/src/developer/lang.rs +++ b/crates/goose-mcp/src/developer/lang.rs @@ -11,6 +11,9 @@ pub fn get_language_identifier(path: &Path) -> &'static str { Some("toml") => "toml", Some("yaml") | Some("yml") => "yaml", Some("sh") => "bash", + Some("ps1") => "powershell", + Some("bat") | Some("cmd") => "batch", + Some("vbs") => "vbscript", Some("go") => "go", Some("md") => "markdown", Some("html") => "html", diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs index 5a33ce6d9b..a55372cb09 100644 --- a/crates/goose-mcp/src/developer/mod.rs +++ b/crates/goose-mcp/src/developer/mod.rs @@ -1,4 +1,5 @@ mod lang; +mod shell; use anyhow::Result; use base64::Engine; @@ -31,6 +32,11 @@ use std::process::Stdio; use std::sync::{Arc, Mutex}; use xcap::{Monitor, Window}; +use self::shell::{ + expand_path, format_command_for_platform, get_shell_config, is_absolute_path, + normalize_line_endings, +}; + pub struct DeveloperRouter { tools: Vec, file_history: Arc>>>, @@ -48,9 +54,30 @@ impl DeveloperRouter { // TODO consider rust native search tools, we could use // https://docs.rs/ignore/latest/ignore/ - let bash_tool = Tool::new( - "shell".to_string(), - indoc! {r#" + // Get OS-specific shell tool description + let shell_tool_desc = match std::env::consts::OS { + "windows" => indoc! {r#" + Execute a command in the shell. + + This will return the output and error concatenated into a single string, as + you would see from running on the command line. There will also be an indication + of if the command succeeded or failed. + + Avoid commands that produce a large amount of output, and consider piping those outputs to files. + + **Important**: For searching files and code: + + Preferred: Use ripgrep (`rg`) when available - it respects .gitignore and is fast: + - To locate a file by name: `rg --files | rg example.py` + - To locate content inside files: `rg 'class Example'` + + Alternative Windows commands (if ripgrep is not installed): + - To locate a file by name: `dir /s /b example.py` + - To locate content inside files: `findstr /s /i "class Example" *.py` + + Note: Alternative commands may show ignored/hidden files that should be excluded. + "#}, + _ => indoc! {r#" Execute a command in the shell. This will return the output and error concatenated into a single string, as @@ -64,8 +91,13 @@ impl DeveloperRouter { **Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions may show ignored or hidden files. For example *do not* use `find` or `ls -r` - To locate a file by name: `rg --files | rg example.py` - - To locate consent inside files: `rg 'class Example'` - "#}.to_string(), + - To locate content inside files: `rg 'class Example'` + "#}, + }; + + let bash_tool = Tool::new( + "shell".to_string(), + shell_tool_desc.to_string(), json!({ "type": "object", "required": ["command"], @@ -157,9 +189,31 @@ impl DeveloperRouter { // Get base instructions and working directory let cwd = std::env::current_dir().expect("should have a current working dir"); - let base_instructions = formatdoc! {r#" - The developer extension gives you the capabilities to edit code files and run shell commands, - and can be used to solve a wide range of problems. + let os = std::env::consts::OS; + + let base_instructions = match os { + "windows" => formatdoc! {r#" + The developer extension gives you the capabilities to edit code files and run shell commands, + and can be used to solve a wide range of problems. + + You can use the shell tool to run Windows commands (PowerShell or CMD). + When using paths, you can use either backslashes or forward slashes. + + Use the shell tool as needed to locate files or interact with the project. + + Your windows/screen tools can be used for visual debugging. You should not use these tools unless + prompted to, but you can mention they are available if they are relevant. + + operating system: {os} + current directory: {cwd} + + "#, + os=os, + cwd=cwd.to_string_lossy(), + }, + _ => formatdoc! {r#" + The developer extension gives you the capabilities to edit code files and run shell commands, + and can be used to solve a wide range of problems. You can use the shell tool to run any command that would work on the relevant operating system. Use the shell tool as needed to locate files or interact with the project. @@ -170,9 +224,10 @@ impl DeveloperRouter { operating system: {os} current directory: {cwd} - "#, - os=std::env::consts::OS, - cwd=cwd.to_string_lossy(), + "#, + os=os, + cwd=cwd.to_string_lossy(), + }, }; // Check for global hints in ~/.config/goose/.goosehints @@ -223,15 +278,15 @@ impl DeveloperRouter { } } - // Helper method to resolve a path relative to cwd + // Helper method to resolve a path relative to cwd with platform-specific handling fn resolve_path(&self, path_str: &str) -> Result { let cwd = std::env::current_dir().expect("should have a current working dir"); - let expanded = shellexpand::tilde(path_str); - let path = Path::new(expanded.as_ref()); + let expanded = expand_path(path_str); + let path = Path::new(&expanded); let suggestion = cwd.join(path); - match path.is_absolute() { + match is_absolute_path(&expanded) { true => Ok(path.to_path_buf()), false => Err(ToolError::InvalidParameters(format!( "The path {} is not an absolute path, did you possibly mean {}?", @@ -241,7 +296,7 @@ impl DeveloperRouter { } } - // Implement bash tool functionality + // Shell command execution with platform-specific handling async fn bash(&self, params: Value) -> Result, ToolError> { let command = params @@ -251,19 +306,17 @@ impl DeveloperRouter { "The command string is required".to_string(), ))?; - // TODO consider command suggestions and safety rails + // Get platform-specific shell configuration + let shell_config = get_shell_config(); + let cmd_with_redirect = format_command_for_platform(command); - // TODO be more careful about backgrounding, revisit interleave - // Redirect stderr to stdout to interleave outputs - let cmd_with_redirect = format!("{} 2>&1", command); - - // Execute the command - let child = Command::new("bash") - .stdout(Stdio::piped()) // These two pipes required to capture output later. + // Execute the command using platform-specific shell + let child = Command::new(&shell_config.executable) + .stdout(Stdio::piped()) .stderr(Stdio::piped()) .stdin(Stdio::null()) - .kill_on_drop(true) // Critical so that the command is killed when the agent.reply stream is interrupted. - .arg("-c") + .kill_on_drop(true) + .arg(&shell_config.arg) .arg(cmd_with_redirect) .spawn() .map_err(|e| ToolError::ExecutionError(e.to_string()))?; @@ -417,12 +470,15 @@ impl DeveloperRouter { path: &PathBuf, file_text: &str, ) -> Result, ToolError> { + // Normalize line endings based on platform + let normalized_text = normalize_line_endings(file_text); + // Write to the file - std::fs::write(path, file_text) + std::fs::write(path, normalized_text) .map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?; // Try to detect the language from the file extension - let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + let language = lang::get_language_identifier(path); // The assistant output does not show the file again because the content is already in the tool request // but we do show it to the user here @@ -478,13 +534,14 @@ impl DeveloperRouter { // Save history for undo self.save_file_history(path)?; - // Replace and write back + // Replace and write back with platform-specific line endings let new_content = content.replace(old_str, new_str); - std::fs::write(path, &new_content) + let normalized_content = normalize_line_endings(&new_content); + std::fs::write(path, &normalized_content) .map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?; // Try to detect the language from the file extension - let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + let language = lang::get_language_identifier(path); // Show a snippet of the changed content with context const SNIPPET_LINES: usize = 4; @@ -811,65 +868,28 @@ mod tests { #[tokio::test] #[serial] - async fn test_text_editor_size_limits() { - // Create temp directory first so it stays in scope for the whole test - let temp_dir = tempfile::tempdir().unwrap(); - std::env::set_current_dir(&temp_dir).unwrap(); - - // Get router after setting current directory + #[cfg(windows)] + async fn test_windows_specific_commands() { let router = get_router().await; - // Test file size limit - { - let large_file_path = temp_dir.path().join("large.txt"); - let large_file_str = large_file_path.to_str().unwrap(); + // Test PowerShell command + let result = router + .call_tool( + "shell", + json!({ + "command": "Get-ChildItem" + }), + ) + .await; + assert!(result.is_ok()); - // Create a file larger than 2MB - let content = "x".repeat(3 * 1024 * 1024); // 3MB - std::fs::write(&large_file_path, content).unwrap(); + // Test Windows path handling + let result = router.resolve_path("C:\\Windows\\System32"); + assert!(result.is_ok()); - let result = router - .call_tool( - "text_editor", - json!({ - "command": "view", - "path": large_file_str - }), - ) - .await; - - assert!(result.is_err()); - let err = result.err().unwrap(); - assert!(matches!(err, ToolError::ExecutionError(_))); - assert!(err.to_string().contains("too large")); - } - - // Test character count limit - { - let many_chars_path = temp_dir.path().join("many_chars.txt"); - let many_chars_str = many_chars_path.to_str().unwrap(); - - // Create a file with more than 400K characters but less than 400KB - let content = "x".repeat(405_000); - std::fs::write(&many_chars_path, content).unwrap(); - - let result = router - .call_tool( - "text_editor", - json!({ - "command": "view", - "path": many_chars_str - }), - ) - .await; - - assert!(result.is_err()); - let err = result.err().unwrap(); - assert!(matches!(err, ToolError::ExecutionError(_))); - assert!(err.to_string().contains("too many characters")); - } - - // Let temp_dir drop naturally at end of scope + // Test UNC path handling + let result = router.resolve_path("\\\\server\\share"); + assert!(result.is_ok()); } #[tokio::test] diff --git a/crates/goose-mcp/src/developer/shell.rs b/crates/goose-mcp/src/developer/shell.rs new file mode 100644 index 0000000000..b323395e7f --- /dev/null +++ b/crates/goose-mcp/src/developer/shell.rs @@ -0,0 +1,72 @@ +use std::env; + +#[derive(Debug, Clone)] +pub struct ShellConfig { + pub executable: String, + pub arg: String, + pub redirect_syntax: String, +} + +impl Default for ShellConfig { + fn default() -> Self { + if cfg!(windows) { + // Use cmd.exe for simpler command execution + Self { + executable: "cmd.exe".to_string(), + arg: "/C".to_string(), + redirect_syntax: "2>&1".to_string(), // cmd.exe also supports this syntax + } + } else { + Self { + executable: "bash".to_string(), + arg: "-c".to_string(), + redirect_syntax: "2>&1".to_string(), + } + } + } +} + +pub fn get_shell_config() -> ShellConfig { + ShellConfig::default() +} + +pub fn format_command_for_platform(command: &str) -> String { + let config = get_shell_config(); + // For all shells, no braces needed + format!("{} {}", command, config.redirect_syntax) +} + +pub fn expand_path(path_str: &str) -> String { + if cfg!(windows) { + // Expand Windows environment variables (%VAR%) + let with_userprofile = path_str.replace( + "%USERPROFILE%", + &env::var("USERPROFILE").unwrap_or_default(), + ); + // Add more Windows environment variables as needed + with_userprofile.replace("%APPDATA%", &env::var("APPDATA").unwrap_or_default()) + } else { + // Unix-style expansion + shellexpand::tilde(path_str).into_owned() + } +} + +pub fn is_absolute_path(path_str: &str) -> bool { + if cfg!(windows) { + // Check for Windows absolute paths (drive letters and UNC) + path_str.contains(":\\") || path_str.starts_with("\\\\") + } else { + // Unix absolute paths start with / + path_str.starts_with('/') + } +} + +pub fn normalize_line_endings(text: &str) -> String { + if cfg!(windows) { + // Ensure CRLF line endings on Windows + text.replace("\r\n", "\n").replace("\n", "\r\n") + } else { + // Ensure LF line endings on Unix + text.replace("\r\n", "\n") + } +} diff --git a/crates/goose-server/src/logging.rs b/crates/goose-server/src/logging.rs index 1077f25133..dc4ea80beb 100644 --- a/crates/goose-server/src/logging.rs +++ b/crates/goose-server/src/logging.rs @@ -12,7 +12,12 @@ use goose::tracing::langfuse_layer; /// Returns the directory where log files should be stored. /// Creates the directory structure if it doesn't exist. fn get_log_directory() -> Result { - let home = std::env::var("HOME").context("HOME environment variable not set")?; + let home = if cfg!(windows) { + std::env::var("USERPROFILE").context("USERPROFILE environment variable not set")? + } else { + std::env::var("HOME").context("HOME environment variable not set")? + }; + let base_log_dir = PathBuf::from(home) .join(".config") .join("goose") diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index a706d6a8e2..4c6135ac7e 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -66,6 +66,9 @@ aws-config = { version = "1.1.7", features = ["behavior-version-latest"] } aws-smithy-types = "1.2.12" aws-sdk-bedrockruntime = "1.72.0" +[target.'cfg(target_os = "windows")'.dependencies] +winapi = { version = "0.3", features = ["wincred"] } + [dev-dependencies] criterion = "0.5" tempfile = "3.15.0" diff --git a/crates/goose/src/providers/oauth.rs b/crates/goose/src/providers/oauth.rs index f4bf1f8508..846f7a722b 100644 --- a/crates/goose/src/providers/oauth.rs +++ b/crates/goose/src/providers/oauth.rs @@ -32,7 +32,11 @@ struct TokenCache { fn get_base_path() -> PathBuf { const BASE_PATH: &str = ".config/goose/databricks/oauth"; - let home_dir = std::env::var("HOME").expect("HOME environment variable not set"); + let home_dir = if cfg!(windows) { + std::env::var("USERPROFILE").expect("USERPROFILE environment variable not set") + } else { + std::env::var("HOME").expect("HOME environment variable not set") + }; PathBuf::from(home_dir).join(BASE_PATH) } diff --git a/crates/mcp-client/src/transport/stdio.rs b/crates/mcp-client/src/transport/stdio.rs index 2ee4234d7f..59d900540a 100644 --- a/crates/mcp-client/src/transport/stdio.rs +++ b/crates/mcp-client/src/transport/stdio.rs @@ -196,15 +196,24 @@ impl StdioTransport { } async fn spawn_process(&self) -> Result<(Child, ChildStdin, ChildStdout, ChildStderr), Error> { - let mut process = Command::new(&self.command) + let mut command = Command::new(&self.command); + command .envs(&self.env) .args(&self.args) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - // 0 sets the process group ID equal to the process ID - .process_group(0) // don't inherit signal handling from parent process + .kill_on_drop(true); + + // Set process group only on Unix systems + #[cfg(unix)] + command.process_group(0); // don't inherit signal handling from parent process + + // Hide console window on Windows + #[cfg(windows)] + command.creation_flags(0x08000000); // CREATE_NO_WINDOW flag + + let mut process = command .spawn() .map_err(|e| Error::StdioProcessError(e.to_string()))?; diff --git a/ui/desktop/.gitignore b/ui/desktop/.gitignore index a4518409d4..b537c5e29d 100644 --- a/ui/desktop/.gitignore +++ b/ui/desktop/.gitignore @@ -1,4 +1,5 @@ node_modules .vite/ out -src/bin/goosed \ No newline at end of file +src/bin/goosed +/src/bin/goosed.exe diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index c7f9928ef3..eec46b53b8 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -5,6 +5,22 @@ let cfg = { asar: true, extraResource: ['src/bin', 'src/images'], icon: 'src/images/icon', + // Windows specific configuration + win32: { + icon: 'src/images/icon.ico', + certificateFile: process.env.WINDOWS_CERTIFICATE_FILE, + certificatePassword: process.env.WINDOWS_CERTIFICATE_PASSWORD, + rfc3161TimeStampServer: 'http://timestamp.digicert.com', + signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256' + }, + // Protocol registration + protocols: [ + { + name: "GooseProtocol", + schemes: ["goose"] + } + ], + // macOS specific configuration osxSign: { entitlements: 'entitlements.plist', 'entitlements-inherit': 'entitlements.plist', @@ -34,13 +50,14 @@ module.exports = { packagerConfig: cfg, rebuildConfig: {}, makers: [ - { - name: '@electron-forge/maker-squirrel', - config: {}, - }, { name: '@electron-forge/maker-zip', - platforms: ['darwin'], + platforms: ['darwin', 'win32'], + config: { + options: { + icon: 'src/images/icon.ico' + } + } }, { name: '@electron-forge/maker-deb', diff --git a/ui/desktop/package.json b/ui/desktop/package.json index cfb041fdca..d0c799d9c2 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -1,7 +1,7 @@ { "name": "goose-app", "productName": "Goose", - "version": "1.0.5", + "version": "1.0.51", "description": "Goose App", "main": ".vite/build/main.js", "scripts": { @@ -11,6 +11,7 @@ "package": "electron-forge package", "make": "electron-forge make", "bundle:default": "npm run make && cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip", + "bundle:windows": "npm run make -- --platform=win32 --arch=x64 && node scripts/copy-windows-dlls.js", "debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app", "test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 12 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi", "lint": "eslint \"src/**/*.{ts,tsx}\" --fix", diff --git a/ui/desktop/scripts/copy-windows-dlls.js b/ui/desktop/scripts/copy-windows-dlls.js new file mode 100644 index 0000000000..369d303e40 --- /dev/null +++ b/ui/desktop/scripts/copy-windows-dlls.js @@ -0,0 +1,84 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Required DLLs that must be present +const REQUIRED_DLLS = [ + 'libstdc++-6.dll', + 'libgcc_s_seh-1.dll', + 'libwinpthread-1.dll' +]; + +// Source and target directories +const sourceDir = path.join(__dirname, '../src/bin'); +const targetDir = path.join(__dirname, '../out/Goose-win32-x64/resources/bin'); + +function ensureDirectoryExists(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(`Created directory: ${dir}`); + } +} + +function copyDLLs() { + // Ensure target directory exists + ensureDirectoryExists(targetDir); + + // Get list of DLLs in source directory + const sourceDLLs = fs.readdirSync(sourceDir) + .filter(file => file.toLowerCase().endsWith('.dll')); + + console.log('Found DLLs in source directory:', sourceDLLs); + + // Check for missing required DLLs + const missingDLLs = REQUIRED_DLLS.filter(dll => + !sourceDLLs.includes(dll) + ); + + if (missingDLLs.length > 0) { + console.error('Missing required DLLs:', missingDLLs); + process.exit(1); + } + + // Copy all DLLs and the executable to target directory + sourceDLLs.forEach(dll => { + const sourcePath = path.join(sourceDir, dll); + const targetPath = path.join(targetDir, dll); + + try { + fs.copyFileSync(sourcePath, targetPath); + console.log(`Copied ${dll} to ${targetDir}`); + } catch (err) { + console.error(`Error copying ${dll}:`, err); + process.exit(1); + } + }); + + // Copy the executable + const exeName = 'goosed.exe'; + const sourceExe = path.join(sourceDir, exeName); + const targetExe = path.join(targetDir, exeName); + + try { + if (fs.existsSync(sourceExe)) { + fs.copyFileSync(sourceExe, targetExe); + console.log(`Copied ${exeName} to ${targetDir}`); + } else { + console.error(`${exeName} not found in source directory`); + process.exit(1); + } + } catch (err) { + console.error(`Error copying ${exeName}:`, err); + process.exit(1); + } + + console.log('All files copied successfully'); +} + +// Main execution +try { + copyDLLs(); +} catch (err) { + console.error('Error during copy process:', err); + process.exit(1); +} \ No newline at end of file diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 06cc1ea4fd..53e95e290a 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -14,7 +14,6 @@ import WingToWing, { Working } from './components/WingToWing'; import { askAi } from './utils/askAI'; import { getStoredModel, Provider } from './utils/providerUtils'; import { ChatLayout } from './components/chat_window/ChatLayout'; -import { ChatRoutes } from './components/chat_window/ChatRoutes'; import { WelcomeScreen } from './components/welcome_screen/WelcomeScreen'; import { getStoredProvider, initializeSystem } from './utils/providerUtils'; import { useModel } from './components/settings/models/ModelContext'; @@ -22,6 +21,9 @@ import { useRecentModels } from './components/settings/models/RecentModels'; import { createSelectedModel } from './components/settings/models/utils'; import { getDefaultModel } from './components/settings/models/hardcoded_stuff'; import Splash from './components/Splash'; +import Settings from './components/settings/Settings'; +import MoreModelsSettings from './components/settings/models/MoreModels'; +import ConfigureProviders from './components/settings/providers/ConfigureProviders'; export interface Chat { id: number; @@ -33,13 +35,19 @@ export interface Chat { }>; } +export type View = 'welcome' | 'chat' | 'settings' | 'moreModels' | 'configureProviders'; + +// This component is our main chat content. +// We'll move the majority of chat logic here, minus the 'view' state. export function ChatContent({ chats, setChats, selectedChatId, + setSelectedChatId, initialQuery, setProgressMessage, setWorking, + setView, }: { chats: Chat[]; setChats: React.Dispatch>; @@ -48,6 +56,7 @@ export function ChatContent({ initialQuery: string | null; setProgressMessage: React.Dispatch>; setWorking: React.Dispatch>; + setView: (view: View) => void; }) { const chat = chats.find((c: Chat) => c.id === selectedChatId); const [messageMetadata, setMessageMetadata] = useState>({}); @@ -95,7 +104,6 @@ export function ChatContent({ window.electron.logInfo('last interaction:' + lastInteractionTime); if (timeSinceLastInteraction > 60000) { // 60000ms = 1 minute - window.electron.showNotification({ title: 'Goose finished the task.', body: 'Click here to expand.', @@ -133,7 +141,7 @@ export function ChatContent({ setLastInteractionTime(Date.now()); append({ role: 'user', - content: content, + content, }); if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); @@ -194,7 +202,8 @@ export function ChatContent({ return (
- + {/* Pass setView to MoreMenu so it can switch to settings or other views */} +
{messages.length === 0 ? ( @@ -215,12 +224,6 @@ export function ChatContent({ )}
))} - {/* {isLoading && ( -
-
setShowGame(true)} style={{ cursor: 'pointer' }}> -
-
- )} */} {error && (
@@ -258,7 +261,7 @@ export function ChatContent({ isLoading={isLoading} onStop={onStopGoose} /> - +
@@ -268,98 +271,59 @@ export function ChatContent({ } export default function ChatWindow() { + // We'll add a state controlling which "view" is active. + const [view, setView] = useState('welcome'); + // Shared function to create a chat window const openNewChatWindow = () => { window.electron.createChatWindow(); }; - const { switchModel, currentModel } = useModel(); // Access switchModel via useModel - const { addRecentModel } = useRecentModels(); // Access addRecentModel from useRecentModels + const { switchModel } = useModel(); + const { addRecentModel } = useRecentModels(); - // Add keyboard shortcut handler + // This will store chat data for the "chat" view. + const [chats, setChats] = useState(() => [ + { + id: 1, + title: 'Chat 1', + messages: [], + }, + ]); + const [selectedChatId, setSelectedChatId] = useState(1); + + // Additional states + const [mode, setMode] = useState<'expanded' | 'compact'>('expanded'); + const [working, setWorking] = useState(Working.Idle); + const [progressMessage, setProgressMessage] = useState(''); + const [initialQuery, setInitialQuery] = useState(null); + + // Keyboard shortcut handler useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - // Check for Command+N (Mac) or Control+N (Windows/Linux) if ((event.metaKey || event.ctrlKey) && event.key === 'n') { - event.preventDefault(); // Prevent default browser behavior + event.preventDefault(); openNewChatWindow(); } }; - // Add event listener window.addEventListener('keydown', handleKeyDown); - - // Cleanup return () => { window.removeEventListener('keydown', handleKeyDown); }; }, []); - // Get initial query and history from URL parameters - const searchParams = new URLSearchParams(window.location.search); - const initialQuery = searchParams.get('initialQuery'); - const historyParam = searchParams.get('history'); - const initialHistory = historyParam ? JSON.parse(decodeURIComponent(historyParam)) : []; - - const [chats, setChats] = useState(() => { - const firstChat = { - id: 1, - title: initialQuery || 'Chat 1', - messages: initialHistory.length > 0 ? initialHistory : [], - }; - return [firstChat]; - }); - - const [selectedChatId, setSelectedChatId] = useState(1); - const [mode, setMode] = useState<'expanded' | 'compact'>(initialQuery ? 'compact' : 'expanded'); - const [working, setWorking] = useState(Working.Idle); - const [progressMessage, setProgressMessage] = useState(''); - const [selectedProvider, setSelectedProvider] = useState(null); - const [showWelcomeModal, setShowWelcomeModal] = useState(true); - - // Add this useEffect to track changes and update welcome state - const toggleMode = () => { - const newMode = mode === 'expanded' ? 'compact' : 'expanded'; - console.log(`Toggle to ${newMode}`); - setMode(newMode); - }; - - window.electron.logInfo('ChatWindow loaded'); - - // Fix the handleSubmit function syntax - const handleSubmit = () => { - setShowWelcomeModal(false); - }; - + // Attempt to detect config for a stored provider useEffect(() => { - // Check if we already have a provider set const config = window.electron.getConfig(); const storedProvider = getStoredProvider(config); - if (storedProvider) { - setShowWelcomeModal(false); + setView('chat'); } else { - setShowWelcomeModal(true); + setView('welcome'); } }, []); - const storeSecret = async (key: string, value: string) => { - const response = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ key, value }), - }); - - if (!response.ok) { - throw new Error(`Failed to store secret: ${response.statusText}`); - } - - return response; - }; - - // Initialize system on load if we have a stored provider + // Initialize system if we have a stored provider useEffect(() => { const setupStoredProvider = async () => { const config = window.electron.getConfig(); @@ -378,19 +342,10 @@ export default function ChatWindow() { await initializeSystem(storedProvider, storedModel); if (!storedModel) { - // get the default model const modelName = getDefaultModel(storedProvider.toLowerCase()); - - // create model object const model = createSelectedModel(storedProvider.toLowerCase(), modelName); - - // Call the context's switchModel to track the set model state in the front end switchModel(model); - - // Keep track of the recently used models addRecentModel(model); - - console.log('set up provider with default model', storedProvider, modelName); } } catch (error) { console.error('Failed to initialize with stored provider:', error); @@ -401,24 +356,58 @@ export default function ChatWindow() { setupStoredProvider(); }, []); - // Render WelcomeScreen at root level if showing - if (showWelcomeModal) { - return ; - } + // Render everything inside ChatLayout now + // We'll switch views inside the ChatLayout children. + + // If we want to skip showing ChatLayout for the welcome screen, we can do so. + // But let's do exactly what's requested: put all view options under ChatLayout. - // Only render ChatLayout if not showing welcome screen return ( -
- - + {/* Conditionally render based on `view` */} + {view === 'welcome' && ( + { + setView('chat'); + }} + /> + )} + {view === 'settings' && ( + { + setView('chat'); + }} + setView={setView} + /> + )} + {view === 'moreModels' && ( + { + setView('settings'); + }} + setView={setView} + /> + )} + {view === 'configureProviders' && ( + { + setView('settings'); + }} + setView={setView} + /> + )} + {view === 'chat' && ( + - -
+ )} + ); } diff --git a/ui/desktop/src/bin/libgcc_s_seh-1.dll b/ui/desktop/src/bin/libgcc_s_seh-1.dll new file mode 100755 index 0000000000..2a858d4fc7 Binary files /dev/null and b/ui/desktop/src/bin/libgcc_s_seh-1.dll differ diff --git a/ui/desktop/src/bin/libstdc++-6.dll b/ui/desktop/src/bin/libstdc++-6.dll new file mode 100755 index 0000000000..e6c135f72b Binary files /dev/null and b/ui/desktop/src/bin/libstdc++-6.dll differ diff --git a/ui/desktop/src/bin/libwinpthread-1.dll b/ui/desktop/src/bin/libwinpthread-1.dll new file mode 100755 index 0000000000..d87e531869 Binary files /dev/null and b/ui/desktop/src/bin/libwinpthread-1.dll differ diff --git a/ui/desktop/src/bin/npx.cmd b/ui/desktop/src/bin/npx.cmd new file mode 100644 index 0000000000..ab991abfc2 --- /dev/null +++ b/ui/desktop/src/bin/npx.cmd @@ -0,0 +1,20 @@ +:: Created by npm, please don't edit manually. +@ECHO OFF + +SETLOCAL + +SET "NODE_EXE=%~dp0\node.exe" +IF NOT EXIST "%NODE_EXE%" ( + SET "NODE_EXE=node" +) + +SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js" +SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js" +FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') DO ( + SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js" +) +IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" ( + SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%" +) + +"%NODE_EXE%" "%NPX_CLI_JS%" %* diff --git a/ui/desktop/src/bin/uv.exe b/ui/desktop/src/bin/uv.exe new file mode 100644 index 0000000000..dbfa26d83c Binary files /dev/null and b/ui/desktop/src/bin/uv.exe differ diff --git a/ui/desktop/src/bin/uvx.exe b/ui/desktop/src/bin/uvx.exe new file mode 100644 index 0000000000..15b125d4f7 Binary files /dev/null and b/ui/desktop/src/bin/uvx.exe differ diff --git a/ui/desktop/src/components/BottomMenu.tsx b/ui/desktop/src/components/BottomMenu.tsx index 99eae44e84..0687fdede7 100644 --- a/ui/desktop/src/components/BottomMenu.tsx +++ b/ui/desktop/src/components/BottomMenu.tsx @@ -3,14 +3,21 @@ import { useModel } from './settings/models/ModelContext'; import { useRecentModels } from './settings/models/RecentModels'; // Hook for recent models import { Sliders } from 'lucide-react'; import { ModelRadioList } from './settings/models/ModelRadioList'; -import { useNavigate } from 'react-router-dom'; +// Remove react-router-dom usage +// import { useNavigate } from 'react-router-dom'; import { Document, ChevronUp, ChevronDown } from './icons'; +import type { View } from '../ChatWindow'; -export default function BottomMenu({ hasMessages }) { +export default function BottomMenu({ + hasMessages, + setView, +}: { + hasMessages: boolean; + setView?: (view: View) => void; +}) { const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const { currentModel } = useModel(); const { recentModels } = useRecentModels(); // Get recent models - const navigate = useNavigate(); const dropdownRef = useRef(null); // Add effect to handle clicks outside @@ -126,7 +133,8 @@ export default function BottomMenu({ hasMessages }) { border-t border-borderSubtle mt-2" onClick={() => { setIsModelMenuOpen(false); - navigate('/settings'); + // Instead of navigate('/settings'), call setView('settings'). + setView?.('settings'); }} > Tools and Settings diff --git a/ui/desktop/src/components/MoreMenu.tsx b/ui/desktop/src/components/MoreMenu.tsx index 1ec8b3d72c..236d693a0f 100644 --- a/ui/desktop/src/components/MoreMenu.tsx +++ b/ui/desktop/src/components/MoreMenu.tsx @@ -2,18 +2,20 @@ import { Popover, PopoverContent, PopoverTrigger, PopoverPortal } from '@radix-u import React, { useEffect, useState } from 'react'; import { FaMoon, FaSun } from 'react-icons/fa'; import VertDots from './ui/VertDots'; -import { useNavigate } from 'react-router-dom'; +// Removed react-router-dom import +// import { useNavigate } from 'react-router-dom'; import { More } from './icons'; import { Settings, Grid, MessageSquare } from 'lucide-react'; import { Button } from './ui/button'; +import type { View } from '../../ChatWindow'; interface VersionInfo { current_version: string; available_versions: string[]; } -export default function MoreMenu() { - const navigate = useNavigate(); +// Accept setView as a prop from the parent (e.g. ChatContent) +export default function MoreMenu({ setView }: { setView?: (view: View) => void }) { const [open, setOpen] = useState(false); const [versions, setVersions] = useState(null); const [showVersions, setShowVersions] = useState(false); @@ -229,7 +231,8 @@ export default function MoreMenu() { + {/* Provider keys settings */} {process.env.NODE_ENV === 'development' && ( @@ -255,7 +251,7 @@ export default function Settings() { setExtensionBeingConfigured(extension)} /> @@ -273,7 +269,6 @@ export default function Settings() { isOpen={!!extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id)} onClose={() => { setExtensionBeingConfigured(null); - navigate('/settings', { replace: true }); }} extension={extensionBeingConfigured} onSubmit={handleExtensionConfigSubmit} @@ -283,8 +278,6 @@ export default function Settings() { isOpen={!!extensionBeingConfigured} onClose={() => { setExtensionBeingConfigured(null); - // Clear URL parameters when closing manually - navigate('/settings', { replace: true }); }} extension={extensionBeingConfigured} onSubmit={handleExtensionConfigSubmit} diff --git a/ui/desktop/src/components/settings/models/MoreModels.tsx b/ui/desktop/src/components/settings/models/MoreModels.tsx index 848f562b85..cb7c3ab3dc 100644 --- a/ui/desktop/src/components/settings/models/MoreModels.tsx +++ b/ui/desktop/src/components/settings/models/MoreModels.tsx @@ -6,25 +6,27 @@ import BackButton from '../../ui/BackButton'; import { SearchBar } from './Search'; import { useModel } from './ModelContext'; import { AddModelInline } from './AddModelInline'; -import { useNavigate } from 'react-router-dom'; +// Removed react-router-dom usage +// import { useNavigate } from 'react-router-dom'; import { ScrollArea } from '../../ui/scroll-area'; +import type { View } from '../../../ChatWindow'; -export default function MoreModelsPage() { +export default function MoreModelsPage({ + onClose, + setView, +}: { + onClose: () => void; + setView: (view: View) => void; +}) { const { currentModel } = useModel(); - const navigate = useNavigate(); return (
- {/* - Instead of forcing one row, allow the layout - to stack vertically on small screens: - */} -
- +

Browse models

@@ -34,7 +36,7 @@ export default function MoreModelsPage() {

Models