cleanup: remove current goose2 code (#9368)

This commit is contained in:
Alex Hancock
2026-05-22 14:21:54 -04:00
committed by GitHub
parent 612cd89d51
commit 4a16d7ed4f
725 changed files with 5 additions and 112854 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
version: 2
updates:
# pnpm workspace for the UI (desktop, acp, text, sdk, goose-binary/*, goose2).
# pnpm workspace for the UI (desktop, acp, text, sdk, goose-binary/*).
# Point at the workspace ROOT where pnpm-lock.yaml lives so Dependabot updates
# both the child package.json AND ui/pnpm-lock.yaml in one PR.
- package-ecosystem: "npm"
@@ -1,26 +0,0 @@
name: "Manual Goose 2 Bundle (Unsigned)"
on:
workflow_dispatch:
inputs:
branch:
description: "Branch name to bundle app from"
required: true
type: string
cli-run-id:
description: "Run ID of a build-cli workflow to pull the goose binary from (optional, builds from source if empty)"
required: false
type: string
default: ""
jobs:
bundle-goose2:
uses: ./.github/workflows/bundle-goose2.yml
permissions:
id-token: write
contents: read
actions: read
with:
signing: false
ref: ${{ inputs.branch }}
cli-run-id: ${{ inputs.cli-run-id }}
-774
View File
@@ -1,774 +0,0 @@
# Reusable workflow that bundles the Goose 2 (Tauri) desktop app.
# Produces .app / .dmg on macOS and .deb / .AppImage on Linux.
#
# The justfile recipe is: `just bundle` → `pnpm tauri build`
#
# Called from release.yml, canary.yml, or manually via bundle-goose2-manual.yml.
#
# The goose CLI binary can either be built from source (default) or pulled
# from a prior build-cli.yml run by passing `cli-run-id`. This avoids
# redundant ~20min Rust builds during release pipelines.
name: "Bundle Goose 2 Desktop"
on:
workflow_call:
inputs:
version:
description: "Version to set for the build (leave empty to use Cargo.toml default)"
required: false
default: ""
type: string
signing:
description: "Whether to perform Apple signing and notarization (macOS only)"
required: false
default: false
type: boolean
quick_test:
description: "Whether to perform the quick launch test (macOS only)"
required: false
default: true
type: boolean
ref:
description: "Git ref to checkout (branch, tag, or SHA). Defaults to the triggering ref."
required: false
default: ""
type: string
environment:
description: "GitHub Environment containing signing secrets. Leave empty to skip."
required: false
default: ""
type: string
windows-signing:
description: "Whether to perform Windows signing via Azure Trusted Signing"
required: false
default: false
type: boolean
cli-run-id:
description: >
Run ID of a prior build-cli.yml workflow run to download the goose
binary from. When empty, the goose CLI is built from source.
required: false
default: ""
type: string
jobs:
# ───────────────────────────────────────────────
# macOS ARM (Apple Silicon)
# ───────────────────────────────────────────────
bundle-macos-arm:
name: "macOS ARM64"
runs-on: macos-latest
environment: ${{ inputs.environment || '' }}
timeout-minutes: 60
env:
MACOSX_DEPLOYMENT_TARGET: "12.0"
permissions:
id-token: write
contents: read
actions: read
outputs:
artifact-url: ${{ steps.upload.outputs.artifact-url }}
steps:
- name: Debug workflow info
env:
INPUT_REF: ${{ inputs.ref }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_SIGNING: ${{ inputs.signing }}
INPUT_CLI_RUN_ID: ${{ inputs.cli-run-id }}
run: |
echo "=== Goose 2 Bundle (macOS ARM64) ==="
echo "Ref: ${INPUT_REF:-<default>}"
echo "Version: ${INPUT_VERSION:-<from Cargo.toml>}"
echo "Signing: ${INPUT_SIGNING}"
echo "CLI run ID: ${INPUT_CLI_RUN_ID:-<build from source>}"
df -h
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
# ── Version stamps ──
- name: Update versions
if: inputs.version != ''
env:
VERSION: ${{ inputs.version }}
run: |
# Root Cargo.toml (workspace version)
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml
rm -f Cargo.toml.bak
# Tauri Cargo.toml
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml
rm -f ui/goose2/src-tauri/Cargo.toml.bak
# package.json
source ./bin/activate-hermit
cd ui/goose2
npm pkg set "version=${VERSION}"
# ── Goose CLI: download from prior run OR build from source ──
- name: Download goose CLI from build-cli run
if: inputs.cli-run-id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: goose-aarch64-apple-darwin
run-id: ${{ inputs.cli-run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: cli-artifact
- name: Extract downloaded goose CLI
if: inputs.cli-run-id != ''
run: |
TRIPLE="aarch64-apple-darwin"
mkdir -p target/release
# The artifact contains goose-<triple>.tar.bz2 with goose inside
tar -xjf "cli-artifact/goose-${TRIPLE}.tar.bz2" -C target/release/
chmod +x target/release/goose
cp target/release/goose "target/release/goose-${TRIPLE}"
ls -lh "target/release/goose-${TRIPLE}"
- name: Cache Rust dependencies
if: inputs.cli-run-id == ''
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: goose2-macos-arm64
- name: Build goose CLI (release)
if: inputs.cli-run-id == ''
run: |
source ./bin/activate-hermit
cargo build --release -p goose-cli --bin goose
- name: Prepare goose binary with target triple
if: inputs.cli-run-id == ''
run: |
TRIPLE=$(rustc --print host-tuple)
cp target/release/goose "target/release/goose-${TRIPLE}"
ls -lh "target/release/goose-${TRIPLE}"
# ── Frontend: pnpm install + SDK build ──
- name: Cache pnpm dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
ui/goose2/node_modules
ui/sdk/node_modules
.hermit/node/cache
key: goose2-pnpm-macos-arm64-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
goose2-pnpm-macos-arm64-${{ runner.os }}-
- name: Install pnpm dependencies
run: |
source ./bin/activate-hermit
cd ui
pnpm install --frozen-lockfile
- name: Build SDK
run: |
source ./bin/activate-hermit
cd ui/sdk
pnpm build
# ── Apple signing ──
- name: Import Apple signing certificate
if: inputs.signing
uses: ./.github/actions/apple-codesign
with:
certificate-base64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
# ── Tauri bundle ──
- name: Bundle Goose 2 (pnpm tauri build)
env:
APPLE_SIGNING_IDENTITY: ${{ inputs.signing && 'Developer ID Application' || '' }}
APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }}
working-directory: ui/goose2
run: |
source ../../bin/activate-hermit
pnpm tauri build
- name: Clean up signing keychain
if: always()
run: |
if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then
security delete-keychain "$KEYCHAIN_PATH" || true
fi
# ── Upload ──
- name: List bundle output
run: |
BUNDLE_DIR="ui/goose2/src-tauri/target/release/bundle"
echo "=== Bundle contents ==="
find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)"
- name: Upload Goose 2 macOS ARM64 artifact
id: upload
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Goose2-darwin-arm64
path: |
ui/goose2/src-tauri/target/release/bundle/dmg/*.dmg
ui/goose2/src-tauri/target/release/bundle/macos/*.app
if-no-files-found: error
# ── Smoke test ──
- name: Quick launch test
if: inputs.quick_test
run: |
APP_PATH=$(find ui/goose2/src-tauri/target/release/bundle/macos -maxdepth 1 -name "*.app" | head -1)
if [ -z "$APP_PATH" ]; then
echo "No .app found, skipping launch test"
exit 0
fi
xattr -cr "$APP_PATH"
echo "Opening $APP_PATH..."
open -g "$APP_PATH"
sleep 5
if pgrep -f "Goose.app/Contents/MacOS" > /dev/null; then
echo "✅ App is running"
else
echo "❌ App did not stay open"
exit 1
fi
pkill -f "Goose.app/Contents/MacOS" || true
# ───────────────────────────────────────────────
# macOS Intel (x86_64)
# ───────────────────────────────────────────────
bundle-macos-intel:
name: "macOS x86_64"
runs-on: macos-15-intel
environment: ${{ inputs.environment || '' }}
timeout-minutes: 60
env:
MACOSX_DEPLOYMENT_TARGET: "12.0"
permissions:
id-token: write
contents: read
actions: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
- name: Update versions
if: inputs.version != ''
env:
VERSION: ${{ inputs.version }}
run: |
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml
rm -f Cargo.toml.bak
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml
rm -f ui/goose2/src-tauri/Cargo.toml.bak
source ./bin/activate-hermit
cd ui/goose2
npm pkg set "version=${VERSION}"
# ── Goose CLI: download from prior run OR build from source ──
- name: Download goose CLI from build-cli run
if: inputs.cli-run-id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: goose-x86_64-apple-darwin
run-id: ${{ inputs.cli-run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: cli-artifact
- name: Extract downloaded goose CLI
if: inputs.cli-run-id != ''
run: |
TARGET="x86_64-apple-darwin"
mkdir -p target/release
tar -xjf "cli-artifact/goose-${TARGET}.tar.bz2" -C target/release/
chmod +x target/release/goose
cp target/release/goose "target/release/goose-${TARGET}"
ls -lh "target/release/goose-${TARGET}"
- name: Cache Rust dependencies
if: inputs.cli-run-id == ''
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: goose2-macos-x86_64
- name: Install Intel target for both toolchains
if: inputs.cli-run-id == ''
run: |
source ./bin/activate-hermit
rustup target add x86_64-apple-darwin
cd ui/goose2/src-tauri
rustup target add x86_64-apple-darwin
- name: Build goose CLI for Intel (x86_64-apple-darwin)
if: inputs.cli-run-id == ''
run: |
source ./bin/activate-hermit
cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin
- name: Prepare goose binary with target triple
if: inputs.cli-run-id == ''
run: |
TARGET="x86_64-apple-darwin"
mkdir -p target/release
cp "target/${TARGET}/release/goose" "target/release/goose-${TARGET}"
ls -lh "target/release/goose-${TARGET}"
# ── Intel target still needed for Tauri's own Rust build ──
- name: Install Intel target for Tauri toolchain
run: |
source ./bin/activate-hermit
rustup target add x86_64-apple-darwin
cd ui/goose2/src-tauri
rustup target add x86_64-apple-darwin
# ── Frontend ──
- name: Cache pnpm dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
ui/goose2/node_modules
ui/sdk/node_modules
.hermit/node/cache
key: goose2-pnpm-macos-intel-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
goose2-pnpm-macos-intel-${{ runner.os }}-
- name: Install pnpm dependencies
run: |
source ./bin/activate-hermit
cd ui
pnpm install --frozen-lockfile
- name: Build SDK
run: |
source ./bin/activate-hermit
cd ui/sdk
pnpm build
# ── Apple signing ──
- name: Import Apple signing certificate
if: inputs.signing
uses: ./.github/actions/apple-codesign
with:
certificate-base64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
# ── Tauri bundle ──
- name: Bundle Goose 2 for Intel
env:
APPLE_SIGNING_IDENTITY: ${{ inputs.signing && 'Developer ID Application' || '' }}
APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }}
working-directory: ui/goose2
run: |
source ../../bin/activate-hermit
pnpm tauri build --target x86_64-apple-darwin
- name: Clean up signing keychain
if: always()
run: |
if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then
security delete-keychain "$KEYCHAIN_PATH" || true
fi
# ── Upload ──
- name: List bundle output
run: |
BUNDLE_DIR="ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle"
echo "=== Bundle contents ==="
find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)"
- name: Upload Goose 2 macOS Intel artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Goose2-darwin-x64
path: |
ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app
if-no-files-found: error
- name: Quick launch test
if: inputs.quick_test
run: |
APP_PATH=$(find ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/macos -maxdepth 1 -name "*.app" 2>/dev/null | head -1)
if [ -z "$APP_PATH" ]; then
echo "No .app found, skipping launch test"
exit 0
fi
xattr -cr "$APP_PATH"
echo "Opening $APP_PATH..."
open -g "$APP_PATH"
sleep 5
if pgrep -f "Goose.app/Contents/MacOS" > /dev/null; then
echo "✅ App is running"
else
echo "❌ App did not stay open"
exit 1
fi
pkill -f "Goose.app/Contents/MacOS" || true
# ───────────────────────────────────────────────
# Linux x86_64
# ───────────────────────────────────────────────
bundle-linux:
name: "Linux x86_64"
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: read
actions: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
protobuf-compiler \
libvulkan-dev \
libvulkan1 \
glslc
- name: Update versions
if: inputs.version != ''
env:
VERSION: ${{ inputs.version }}
run: |
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml
rm -f Cargo.toml.bak
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml
rm -f ui/goose2/src-tauri/Cargo.toml.bak
source ./bin/activate-hermit
cd ui/goose2
npm pkg set "version=${VERSION}"
# ── Goose CLI: download from prior run OR build from source ──
- name: Download goose CLI from build-cli run
if: inputs.cli-run-id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: goose-x86_64-unknown-linux-gnu
run-id: ${{ inputs.cli-run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: cli-artifact
- name: Extract downloaded goose CLI
if: inputs.cli-run-id != ''
run: |
TRIPLE="x86_64-unknown-linux-gnu"
mkdir -p target/release
tar -xjf "cli-artifact/goose-${TRIPLE}.tar.bz2" -C target/release/
chmod +x target/release/goose
cp target/release/goose "target/release/goose-${TRIPLE}"
ls -lh "target/release/goose-${TRIPLE}"
- name: Cache Rust dependencies
if: inputs.cli-run-id == ''
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: goose2-linux-x86_64
- name: Build goose CLI (release)
if: inputs.cli-run-id == ''
run: |
source ./bin/activate-hermit
cargo build --release -p goose-cli --bin goose --features vulkan
- name: Prepare goose binary with target triple
if: inputs.cli-run-id == ''
run: |
TRIPLE=$(rustc --print host-tuple)
cp target/release/goose "target/release/goose-${TRIPLE}"
ls -lh "target/release/goose-${TRIPLE}"
# ── Frontend ──
- name: Cache pnpm dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
ui/goose2/node_modules
ui/sdk/node_modules
.hermit/node/cache
key: goose2-pnpm-linux-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
goose2-pnpm-linux-${{ runner.os }}-
- name: Install pnpm dependencies
run: |
source ./bin/activate-hermit
cd ui
pnpm install --frozen-lockfile
- name: Build SDK
run: |
source ./bin/activate-hermit
cd ui/sdk
pnpm build
# ── Tauri bundle ──
- name: Bundle Goose 2 (pnpm tauri build)
working-directory: ui/goose2
run: |
source ../../bin/activate-hermit
pnpm tauri build
# ── Upload ──
- name: List bundle output
run: |
BUNDLE_DIR="ui/goose2/src-tauri/target/release/bundle"
echo "=== Bundle contents ==="
find "$BUNDLE_DIR" -type f 2>/dev/null | head -30
echo ""
echo "=== File sizes ==="
find "$BUNDLE_DIR" -type f -exec ls -lh {} \; 2>/dev/null | head -20
- name: Upload .deb package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Goose2-linux-x64-deb
path: ui/goose2/src-tauri/target/release/bundle/deb/*.deb
if-no-files-found: warn
- name: Upload AppImage
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Goose2-linux-x64-appimage
path: ui/goose2/src-tauri/target/release/bundle/appimage/*.AppImage
if-no-files-found: warn
- name: Upload RPM package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Goose2-linux-x64-rpm
path: ui/goose2/src-tauri/target/release/bundle/rpm/*.rpm
if-no-files-found: warn
# ───────────────────────────────────────────────
# Windows x86_64
# ───────────────────────────────────────────────
bundle-windows:
name: "Windows x86_64"
runs-on: windows-latest
timeout-minutes: 60
permissions:
id-token: write
contents: read
actions: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
# Hermit doesn't work on Windows — install node/pnpm directly
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24
- name: Install pnpm
run: npm install -g pnpm@10.33.0
- name: Update versions
if: inputs.version != ''
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml
rm -f Cargo.toml.bak
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml
rm -f ui/goose2/src-tauri/Cargo.toml.bak
cd ui/goose2
npm pkg set "version=${VERSION}"
# ── Goose CLI: download from prior run OR build from source ──
- name: Download goose CLI from build-cli run
if: inputs.cli-run-id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: goose-x86_64-pc-windows-msvc
run-id: ${{ inputs.cli-run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: cli-artifact
- name: Extract downloaded goose CLI
if: inputs.cli-run-id != ''
shell: bash
run: |
TARGET="x86_64-pc-windows-msvc"
mkdir -p target/release
# The zip contains goose-package/goose.exe
cd cli-artifact
7z x "goose-${TARGET}.zip"
cd ..
cp cli-artifact/goose-package/goose.exe target/release/
# Tauri externalBin appends target triple + .exe on Windows
cp target/release/goose.exe "target/release/goose-${TARGET}.exe"
ls -lh "target/release/goose-${TARGET}.exe"
- name: Cache Rust dependencies
if: inputs.cli-run-id == ''
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: goose2-windows-x86_64
- name: Setup Rust
if: inputs.cli-run-id == ''
shell: bash
run: |
rustup show
rustup target add x86_64-pc-windows-msvc
- name: Build goose CLI (release)
if: inputs.cli-run-id == ''
shell: bash
run: |
cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose
- name: Prepare goose binary with target triple
if: inputs.cli-run-id == ''
shell: bash
run: |
TARGET="x86_64-pc-windows-msvc"
cp "target/${TARGET}/release/goose.exe" "target/release/goose-${TARGET}.exe"
ls -lh "target/release/goose-${TARGET}.exe"
# ── Frontend ──
- name: Cache pnpm dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
ui/goose2/node_modules
ui/sdk/node_modules
key: goose2-pnpm-windows-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
goose2-pnpm-windows-${{ runner.os }}-
- name: Install pnpm dependencies
shell: bash
run: |
cd ui
pnpm install --frozen-lockfile
- name: Build SDK
shell: bash
run: |
cd ui/sdk
pnpm build
# ── Tauri bundle ──
- name: Bundle Goose 2 (pnpm tauri build)
shell: bash
working-directory: ui/goose2
run: |
pnpm tauri build --target x86_64-pc-windows-msvc
# ── Upload ──
- name: List bundle output
shell: bash
run: |
BUNDLE_DIR="ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
echo "=== Bundle contents ==="
find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)"
- name: Upload NSIS installer
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Goose2-windows-x64-nsis
path: ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: warn
- name: Upload MSI installer
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: Goose2-windows-x64-msi
path: ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
if-no-files-found: warn
sign-windows:
name: "Sign Windows installers"
needs: bundle-windows
if: inputs.windows-signing
runs-on: windows-latest
environment: signing
permissions:
id-token: write
contents: read
actions: read
steps:
- name: Download NSIS installer
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-windows-x64-nsis
path: unsigned/nsis
- name: Download MSI installer
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-windows-x64-msi
path: unsigned/msi
- name: Azure login
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Windows installers with Azure Trusted Signing
uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v1
with:
endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
files-folder: ${{ github.workspace }}/unsigned
files-folder-filter: exe,msi
files-folder-recurse: true
- name: Verify signed installers
shell: pwsh
run: |
$files = Get-ChildItem -Path unsigned -Recurse -Include *.exe,*.msi
foreach ($file in $files) {
Write-Output "Verifying signature: $($file.FullName)"
$sig = Get-AuthenticodeSignature $file.FullName
if ($sig.Status -ne "Valid") {
throw "Signature invalid for $($file.Name): $($sig.Status)"
}
Write-Output "✅ Signature valid: $($file.Name)"
}
- name: Upload signed NSIS installer
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: Goose2-windows-x64-nsis-signed
path: unsigned/nsis/*.exe
if-no-files-found: error
- name: Upload signed MSI installer
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: Goose2-windows-x64-msi-signed
path: unsigned/msi/*.msi
if-no-files-found: error
-185
View File
@@ -1,185 +0,0 @@
name: "Goose 2 CI"
on:
push:
branches: [main]
paths:
- "ui/goose2/**"
- "ui/sdk/**"
pull_request:
branches: [main]
paths:
- "ui/goose2/**"
- "ui/sdk/**"
merge_group:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
working-directory: ui/goose2
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
with:
version: 10.30.3
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- name: Build SDK
run: pnpm build
working-directory: ui/sdk
- run: pnpm check
- run: pnpm typecheck
test:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
with:
version: 10.30.3
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- name: Build SDK
run: pnpm build
working-directory: ui/sdk
- run: pnpm test
desktop:
name: Desktop Build & E2E
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
with:
version: 10.30.3
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
with:
rust-src-dir: ui/goose2
- name: Cache Rust
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
~/.cargo/registry
~/.cargo/git
ui/goose2/src-tauri/target
key: ${{ runner.os }}-goose2-cargo-${{ hashFiles('ui/goose2/src-tauri/Cargo.lock') }}
- run: pnpm install --frozen-lockfile
- name: Build SDK
run: pnpm build
working-directory: ui/sdk
- name: Build frontend
run: pnpm build
- name: Mock goose binary
working-directory: .
run: mkdir -p target/release && touch target/release/goose-$(rustc --print host-tuple)
- name: Check Tauri
run: cd src-tauri && cargo check
- name: Clippy
run: cd src-tauri && cargo clippy -- -D warnings
- name: Format check
run: cd src-tauri && cargo fmt --check
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Run E2E tests
run: pnpm exec playwright test
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report
path: |
ui/goose2/playwright-report/
ui/goose2/test-results/
retention-days: 7
rust-lint:
name: Rust Lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf
- name: Install Rust
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
with:
rust-src-dir: ui/goose2
components: rustfmt, clippy
- name: Cache Rust
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
~/.cargo/registry
~/.cargo/git
ui/goose2/src-tauri/target
key: ${{ runner.os }}-goose2-cargo-${{ hashFiles('ui/goose2/src-tauri/Cargo.lock') }}
- name: Mock goose binary
working-directory: .
run: mkdir -p target/release && touch target/release/goose-$(rustc --print host-tuple)
- name: Format check
run: cd src-tauri && cargo fmt --check
- name: Clippy
run: cd src-tauri && cargo clippy -- -D warnings
-192
View File
@@ -1,192 +0,0 @@
on:
push:
tags:
- "v2.*"
workflow_dispatch:
inputs:
version:
description: "Version string (e.g. 2.0.0-rc.1). Used when testing from a branch."
required: true
type: string
cli-run-id:
description: "Run ID of a build-cli workflow to pull goose binaries from (skips CLI build step)"
required: false
type: string
default: ""
name: "Release Goose 2"
permissions:
id-token: write # Sigstore OIDC signing + Azure OIDC (Windows signing)
contents: write # Creating releases + actions/checkout
actions: read # Downloading artifacts across workflow runs
attestations: write # SLSA build provenance attestations
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
prepare-version:
name: Prepare Version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.set-version.outputs.version }}
steps:
- name: Extract version
id: set-version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
else
# Strip the leading "v" from the tag (e.g. v2.0.0 → 2.0.0)
VERSION="${GITHUB_REF_NAME#v}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Release version: $VERSION"
build-cli:
if: inputs.cli-run-id == ''
needs: [prepare-version]
uses: ./.github/workflows/build-cli.yml
with:
version: ${{ needs.prepare-version.outputs.version }}
bundle-goose2:
needs: [prepare-version, build-cli]
if: ${{ !cancelled() && needs.prepare-version.result == 'success' && (needs.build-cli.result == 'success' || needs.build-cli.result == 'skipped') }}
uses: ./.github/workflows/bundle-goose2.yml
permissions:
id-token: write
contents: read
actions: read
with:
version: ${{ needs.prepare-version.outputs.version }}
signing: true
windows-signing: true
environment: signing
cli-run-id: ${{ inputs.cli-run-id != '' && inputs.cli-run-id || github.run_id }}
secrets: inherit
install-script:
name: Upload Install Script
runs-on: ubuntu-latest
if: inputs.cli-run-id == ''
needs: [build-cli]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: download_cli.sh
path: download_cli.sh
release:
name: Release
runs-on: ubuntu-latest
needs: [prepare-version, build-cli, install-script, bundle-goose2]
if: ${{ !cancelled() && needs.bundle-goose2.result == 'success' }}
permissions:
contents: write
id-token: write
actions: read
attestations: write
steps:
- name: Download CLI artifacts
if: needs.build-cli.result == 'success'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: goose-*
merge-multiple: true
path: release
- name: Download install script
if: needs.install-script.result == 'success'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: download_cli.sh
path: release
- name: Download macOS ARM64
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-darwin-arm64
path: release
- name: Download macOS Intel
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-darwin-x64
path: release
- name: Download Linux .deb
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-linux-x64-deb
path: release
continue-on-error: true
- name: Download Linux AppImage
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-linux-x64-appimage
path: release
continue-on-error: true
- name: Download Linux RPM
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-linux-x64-rpm
path: release
continue-on-error: true
- name: Download signed Windows NSIS installer
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-windows-x64-nsis-signed
path: release
- name: Download signed Windows MSI installer
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: Goose2-windows-x64-msi-signed
path: release
- name: List downloaded artifacts
run: |
echo "=== All release artifacts ==="
find release -type f | sort
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
release/goose-*.tar.bz2
release/goose-*.tar.gz
release/goose-*.zip
release/**/*.dmg
release/*.exe
release/*.msi
release/*.deb
release/*.rpm
release/*.AppImage
release/download_cli.sh
# Create/update the versioned pre-release (e.g. v2.0.0)
- name: Release versioned
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: true
artifacts: |
release/goose-*.tar.bz2
release/goose-*.tar.gz
release/goose-*.zip
release/**/*.dmg
release/*.exe
release/*.msi
release/*.deb
release/*.rpm
release/*.AppImage
release/download_cli.sh
allowUpdates: true
omitBody: true
-1
View File
@@ -4,7 +4,6 @@ members = [
# Mainly for cargo-machete to not error out during inspection.
"vendor/v8",
]
exclude = ["ui/goose2/src-tauri"]
resolver = "2"
[workspace.package]
-7
View File
@@ -1,7 +1,5 @@
# Justfile
mod goose2 'ui/goose2'
# list all tasks
default:
@just --list
@@ -450,8 +448,3 @@ build-test-tools:
record-mcp-tests: build-test-tools
GOOSE_RECORD_MCP=1 cargo test --package goose --test mcp_integration_test
git add crates/goose/tests/mcp_replays/
bundle-goose2:
cargo build --release --package goose-cli --bin goose {{linux_vulkan_features}}
cp target/release/goose target/release/goose-$(rustc --print host-tuple)
@just goose2::bundle
@@ -1,380 +0,0 @@
# ACP First Spike: Configured Extensions List
## Purpose
Use configured extensions as the first `ui/desktop` REST-to-ACP migration slice.
This spike is intentionally smaller than chat or sessions. It proves that desktop can talk directly to `/acp` through `goosed` during migration, without depending on ACP session ID behavior or chat streaming semantics.
## Scope
Migrate the read-only configured extensions list behind a feature flag.
```text
Current REST:
GET /config/extensions
Target ACP:
_goose/config/extensions
```
Do not migrate add/update/delete in this first slice. Those can remain REST until the read path and ACP client plumbing are proven.
## Why This Slice
- No session ID dependency.
- No chat streaming or prompt lifecycle.
- No tool approval.
- No message-shape conversion.
- Exercises direct renderer WebSocket to `/acp`.
- Exercises ACP initialization and custom `_goose/*` request dispatch.
- Easy to compare REST and ACP responses.
## Relevant Existing Code
Desktop REST read path:
- `ui/desktop/src/components/ConfigContext.tsx`
- imports `getExtensions as apiGetExtensions`
- `refreshExtensions()` calls `apiGetExtensions()`
- initial provider load also calls `apiGetExtensions()`
Desktop UI consumer:
- `ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx`
- consumes `extensionsList` from `useConfig()`
- calls `getExtensions(true)` to refresh
REST backend:
- `crates/goose-server/src/routes/config_management.rs`
- `GET /config/extensions`
- `POST /config/extensions`
- `DELETE /config/extensions/{name}`
ACP backend:
- `crates/goose/src/acp/server/extensions.rs`
- `_goose/config/extensions`
- `_goose/config/extensions/add`
- `_goose/config/extensions/remove`
- `_goose/config/extensions/toggle`
ACP client reference:
- `ui/goose2/src/shared/api/createWebSocketStream.ts`
- `ui/goose2/src/shared/api/acpConnection.ts`
- `ui/goose2/src/shared/api/acpApi.ts`
Treat `ui/goose2` as a reference only. Do not share runtime code with it because `ui/goose2` is expected to move out of this repo.
## Current Gap Found
REST `GET /config/extensions` filters hidden extensions:
```rust
goose::config::get_all_extensions()
.into_iter()
.filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name()))
```
ACP `_goose/config/extensions` currently calls `crate::config::extensions::get_all_extensions()` and does not apply the same hidden-extension filter.
Recommendation: fix ACP to match REST before enabling this feature flag. The goal is to prove the migration path without introducing UI-visible behavior differences.
## Backend Plan
## Step-By-Step Review Plan
Work in small reviewable slices. After each step, stop and review before moving to the next one.
### Step 1: Inspect router/auth shape
Goal: confirm the exact `goosed` router composition and ACP transport shape before editing.
Review points:
- where REST `X-Secret-Key` middleware is applied
- whether ACP can be mounted as a separate branch
- whether `goose::acp::transport::create_router` causes route collisions
- where ACP token auth should live
- whether Cargo dependencies already allow `goose-server` to call ACP router code
Expected outcome: a precise patch plan for backend mounting.
### Step 2: Add ACP-only router helper
Goal: avoid route collisions by exposing only `/acp` routes for embedding in `goosed`.
Likely file:
- `crates/goose/src/acp/transport/mod.rs`
Reason: existing `create_router(...)` includes `/health`, `/status`, and MCP app proxy routes. `goosed` already owns some of those routes.
Expected outcome: a helper such as `create_acp_router(...)` or equivalent that only mounts:
```text
/acp POST
/acp GET
/acp DELETE
```
### Step 3: Mount `/acp` in `goosed`
Goal: serve REST and ACP from the same `goosed` process during migration.
Likely files:
- `crates/goose-server/src/commands/agent.rs`
- maybe `crates/goose-server/src/routes/mod.rs`
Expected shape:
```text
goosed
REST routes protected by X-Secret-Key
/acp protected by ACP token auth
```
### Step 4: Expose ACP URL/token to renderer
Goal: let renderer connect directly to `/acp` over WebSocket.
Likely files:
- `ui/desktop/src/main.ts`
- `ui/desktop/src/preload.ts`
- related Electron type declarations if present
Expected renderer-facing value:
```text
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
### Step 5: Add minimal desktop ACP client
Goal: create enough client plumbing to initialize ACP and call one custom method.
Suggested files:
```text
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
```
Reference, not shared dependency:
- `ui/goose2/src/shared/api/createWebSocketStream.ts`
- `ui/goose2/src/shared/api/acpConnection.ts`
- `ui/goose2/src/shared/api/acpApi.ts`
### Step 6: Migrate configured extensions read path behind a flag
Goal: call `_goose/config/extensions` for read-only extension listing when enabled.
Primary integration file:
- `ui/desktop/src/components/ConfigContext.tsx`
Keep writes on REST in this slice:
- add extension
- remove extension
- toggle extension
- bundled extension sync/prune
### Step 7: Validate parity
Goal: prove ACP and REST return equivalent visible extension data.
Validation checklist is below.
## Backend Details
### Mount `/acp` in `goosed`
Add the ACP Axum router to `goosed agent`.
Likely files:
- `crates/goose-server/src/commands/agent.rs`
- `crates/goose-server/src/routes/mod.rs`
Use existing ACP pieces:
```rust
goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}
goose::acp::transport::create_router
```
Keep REST and ACP as separate route branches.
Watch for route collisions:
- `/status`
- `/mcp-app-proxy`
- `/mcp-app-guest`
### Add ACP token auth
The renderer should connect through direct WebSocket:
```text
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
Do not put `/acp` behind the REST `X-Secret-Key` middleware because browser WebSocket cannot set arbitrary headers.
Guardrails:
- Use a random ACP token for the `goosed` process.
- Prefer an ACP-specific token over reusing the raw REST `X-Secret-Key`.
- Never log full `/acp?token=...` URLs.
- Accept ACP token auth only for the local desktop backend.
- Keep REST `X-Secret-Key` for REST routes during migration.
### Fix `_goose/config/extensions` parity
Update ACP `_goose/config/extensions` to match REST behavior:
- filter hidden extensions
- preserve warnings
- preserve the response shape needed by desktop
The ACP response currently injects a `config_key` field. Verify the desktop shape expected by `ExtensionEntry` and normalize on the client if needed.
## Desktop Plan
### Add ACP client files
Suggested files:
```text
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
```
Base these on `ui/goose2`, but adapt to Electron.
Differences from `ui/goose2`:
- URL lookup should use Electron, not Tauri.
- Build ACP URL from the existing `goosed` host.
- Append the ACP token.
- Initialize ACP once and reconnect on close.
Example URL shape:
```ts
const baseUrl = await window.electron.getGoosedHostPort();
const acpUrl = baseUrl.replace(/^http/, 'ws') + '/acp?token=' + encodeURIComponent(token);
```
If `goosed` is running HTTPS, this becomes `wss://.../acp?...`.
### Add ACP extensions API wrapper
Add a wrapper such as:
```ts
getConfigExtensionsViaAcp(): Promise<ExtensionResponse>
```
It should call:
```text
_goose/config/extensions
```
Normalize the response to the current desktop `ExtensionResponse` shape:
```ts
type ExtensionResponse = {
extensions: ExtensionEntry[];
warnings: string[];
};
```
### Add a feature flag
Suggested name:
```text
acpConfigExtensions
```
The exact flag mechanism should follow existing desktop feature-flag conventions if present. If there is no suitable framework, use a local env/config gate for the spike.
### Route read path through ACP only when enabled
Primary integration file:
- `ui/desktop/src/components/ConfigContext.tsx`
Candidate call sites:
- `refreshExtensions()`
- initial load effect that fetches extensions after bundled sync
Keep these mutation paths on REST for the first spike:
- `addExtension`
- `removeExtension`
- `toggleExtension`
- bundled extension sync/prune
After REST mutations complete, refresh can use ACP when the flag is enabled.
### Add fallback behavior
For the spike, if ACP connection or `_goose/config/extensions` fails, log the error and fall back to REST.
This fallback is only for the spike. Once a feature area is fully migrated, the matching REST endpoint should be removed.
## Validation Checklist
Compare REST and ACP for the same local config:
- same visible extension count
- same names
- same `enabled` values
- same extension types (`builtin`, `stdio`, `streamable_http`, etc.)
- same warnings
- hidden extensions do not appear
- settings/extensions UI renders correctly
- add/update/delete still work through REST and refresh the list
- failed ACP connection falls back to REST while the spike flag is enabled
## Removal Rule
Do not remove `GET /config/extensions` after this first read-only spike if writes still use REST sync logic that depends on the endpoint.
Remove the REST extension endpoints only after the full extension config surface has migrated:
```text
GET /config/extensions
POST /config/extensions
DELETE /config/extensions/{name}
```
Corresponding ACP methods:
```text
_goose/config/extensions
_goose/config/extensions/add
_goose/config/extensions/remove
_goose/config/extensions/toggle
```
## Done Criteria
- `goosed` exposes token-authenticated `/acp`.
- `ui/desktop` can open a direct renderer WebSocket to `/acp`.
- ACP initialize succeeds.
- `_goose/config/extensions` returns REST-equivalent visible extension data.
- Feature flag can switch extension list reads between REST and ACP.
- Existing extension settings UI behaves the same under the ACP read path.
-707
View File
@@ -1,707 +0,0 @@
# Desktop ACP Migration Spike
## Goal
`ui/desktop` currently talks to `goosed agent` through REST/OpenAPI. We want to migrate the desktop app to talk to Goose through ACP, while keeping the migration gradual and avoiding a large bundle-size increase.
The preferred migration direction is:
- Keep bundling only `goosed` during migration to avoid shipping both large binaries.
- Add ACP serving at `/acp` inside `goosed agent` as a temporary bridge.
- Let `ui/desktop` talk directly to `/acp` for migrated surfaces.
- Keep existing REST routes only for unmigrated areas.
- Once all REST behavior has moved to ACP, stop bundling/running `goosed` and switch desktop to the existing `goose serve` ACP server.
## Current Architecture
### Desktop backend
`goosed` is an Axum HTTP server.
Relevant files:
- `crates/goose-server/src/main.rs`
- `crates/goose-server/src/commands/agent.rs`
- `crates/goose-server/src/routes/mod.rs`
- `ui/desktop/src/goosed.ts`
`ui/desktop/src/goosed.ts` finds and spawns the bundled `goosed` binary:
```ts
const spawnCommand = goosedPath;
const spawnArgs = ['agent'];
```
The renderer configures the generated OpenAPI client against the `goosed` URL in `ui/desktop/src/renderer.tsx`.
### Desktop REST usage
`ui/desktop` imports generated API methods from `ui/desktop/src/api`.
Important chat/session paths today:
- `ui/desktop/src/sessions.ts`
- `startAgent` creates sessions.
- `ui/desktop/src/hooks/useChatStream.ts`
- `resumeAgent`
- `sessionReply`
- `sessionCancel`
- `getSession`
- `updateFromSession`
- `ui/desktop/src/hooks/useSessionEvents.ts`
- opens `GET /sessions/{id}/events` as SSE.
The current REST streaming model is Goose-specific:
- `POST /sessions/{id}/reply`
- `GET /sessions/{id}/events`
- event types such as `Message`, `Finish`, `Error`, `Notification`, `ActiveRequests`
- request routing through `request_id` / `chat_request_id`
## Existing ACP Implementation
Goose already has ACP support.
Relevant files:
- ACP agent implementation: `crates/goose/src/acp/server.rs`
- ACP transport: `crates/goose/src/acp/transport/mod.rs`
- ACP custom methods: `crates/goose/src/acp/server/custom_dispatch.rs`
- Custom request types: `crates/goose-sdk/src/custom_requests.rs`
- CLI entry points: `crates/goose-cli/src/cli.rs`
There are two existing ACP modes:
### `goose acp`
Runs ACP over stdio:
```rust
Some(Command::Acp { builtins }) => goose::acp::server::run(builtins).await
```
This is standard ACP, but in Electron it would require the main process to own stdio and bridge to the renderer.
### `goose serve`
Runs ACP over HTTP/SSE/WebSocket:
```rust
Some(Command::Serve { host, port, builtins }) => handle_serve_command(host, port, builtins).await
```
The transport router registers:
```rust
/health
/status
/acp POST
/acp GET
/acp DELETE
```
`GET /acp` upgrades to WebSocket when requested. Otherwise it behaves as SSE. `POST /acp` accepts JSON-RPC messages.
## Bundle Size Finding
Local release binary sizes:
```text
target/release/goose 230M
target/release/goosed 218M
```
Bundling both would add roughly:
```text
goosed + goose = 448M uncompressed
```
`ui/desktop/src/bin` currently contains both binaries locally:
```text
ui/desktop/src/bin/goose 230M
ui/desktop/src/bin/goosed 218M
```
This is too large as a long-term plan.
## Recommended Backend Strategy
Do not bundle both `goose` and `goosed` for production migration. The temporary bridge is to mount `/acp` into `goosed`; the final backend is `goose serve`.
Migration backend:
1. Add ACP serving to `goosed agent`.
2. Keep REST routes available on the same process during migration.
3. Mount `/acp` in the existing `goosed` Axum app.
4. Gradually migrate desktop feature areas from REST to ACP.
5. Remove each relevant REST endpoint once its feature area is migrated.
Migration shape:
```text
ui/desktop renderer
-> http(s)://127.0.0.1:<port>/acp
goosed process
existing REST routes temporarily
ACP /acp
```
This keeps the backend bundle around current `goosed` size plus a small ACP wiring delta, instead of adding a second 230M binary.
Final backend:
```text
ui/desktop renderer
-> ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
goose serve
standard ACP methods
_goose/* custom ACP methods
```
In the final state `goosed` is not bundled or spawned by the desktop app.
Before deleting the `goosed` bridge, verify that the final `goose serve` path has the same
desktop-specific ACP behavior that the bridge depended on during migration. In particular:
- config and data directories match the desktop app's expected Goose paths
- builtin extension setup matches what desktop needs
- ACP initialization uses the correct desktop platform identity
- any state initialized today by `goosed` startup is either no longer needed or is initialized by
the final desktop `goose serve` launch path
## Migration Bridge vs Final Server
Mounting `/acp` in `goosed` should be treated as a bridge, not the destination.
```text
During migration:
ui/desktop
-> REST for unmigrated features
-> /acp for migrated features
bundled backend:
goosed
REST routes
temporary /acp route
```
```text
After migration:
ui/desktop
-> /acp only
bundled backend:
goose serve
standard ACP
_goose/* custom ACP methods
```
The migration rule is:
```text
When a feature moves to /acp:
remove its corresponding REST endpoint from goosed
```
The final cutover from `goosed agent` to `goose serve` is blocked until no desktop runtime feature depends on REST/OpenAPI.
Expected effort:
- Mounting `/acp` in `goosed`: relatively easy because both servers are Axum and the ACP router already exists.
- Removing `goosed` later: medium effort, because every REST-only desktop capability must first be moved into standard ACP or `_goose/*` custom ACP methods.
## Alternative Considered: Move REST Into `goose serve`
It is possible to make `goose serve` also mount `goose-server` REST routes. That would make `goose-cli` depend on `goose-server`.
Tradeoffs:
- Simpler single `goose` binary packaging.
- But the general-purpose CLI binary gets desktop REST/OpenAPI/tunnel/gateway/server dependencies.
- Likely increases `goose` binary size.
- Blurs the CLI and desktop backend boundary.
The cleaner migration bridge is the reverse: add ACP to `goosed` temporarily. The final target remains `goose serve`, not `goosed`.
## Streaming Differences
Adding `/acp` to `goosed` does not make the current desktop streaming code work unchanged.
### Current REST streaming
The current desktop chat stream expects:
- `GET /sessions/{id}/events`
- Goose-specific `MessageEvent` objects
- `ActiveRequests`
- `request_id` / `chat_request_id`
- `Message`, `Finish`, `Error`, `Notification`
### ACP streaming
ACP streaming is protocol-level:
- Client sends JSON-RPC `session/prompt`.
- Server emits `session/update` notifications.
- Updates include:
- agent message chunks
- user message chunks
- tool calls
- tool call updates
- usage updates
- session info updates
- config option updates
Tool approval also changes:
- REST uses `/action-required/tool-confirmation`.
- ACP sends `RequestPermissionRequest`.
- The client must respond on the ACP connection.
## Recommended Client Strategy
Use WebSocket ACP directly from the renderer.
Preferred shape:
```text
renderer -> wss://127.0.0.1:<port>/acp
send initialize
send session/new
send session/load
send session/prompt
receive session/update notifications continuously
respond to permission requests
```
WebSocket is preferable to HTTP POST + SSE because it gives one bidirectional connection for requests, responses, notifications, and permission responses. Do not add an Electron-main IPC transport layer for normal ACP chat traffic.
## Existing Reference: `ui/goose2`
`ui/goose2` already has a client pattern that can be reused or adapted.
Relevant files:
- `ui/goose2/src/shared/api/createWebSocketStream.ts`
- `ui/goose2/src/shared/api/acpConnection.ts`
- `ui/goose2/src/shared/api/acpApi.ts`
- `ui/goose2/src-tauri/src/services/acp/goose_serve.rs`
`ui/goose2`:
- gets a `/acp` WebSocket URL from Tauri
- creates a WebSocket stream
- creates a `GooseClient`
- initializes ACP with client capabilities
- routes `sessionUpdate` notifications through a handler
- exposes APIs such as:
- `listSessions`
- `newSession`
- `loadSession`
- `prompt`
- `cancelSession`
- `setProvider`
- `setModel`
- `_goose/*` custom methods
`ui/desktop` can use the same pattern, replacing Tauri URL lookup with Electron URL lookup.
Because `ui/goose2` is expected to move out of this repo in the future, desktop should not share runtime code with it. Treat `ui/goose2` as a reference implementation and copy/adapt the small ACP client pieces into `ui/desktop`.
Example URL derivation:
```ts
const baseUrl = await window.electron.getGoosedHostPort();
const acpUrl = baseUrl.replace(/^http/, 'ws') + '/acp';
```
If `goosed` is running HTTPS, this becomes `wss://.../acp`.
## ACP Auth Decision
Current `goosed` REST uses `X-Secret-Key`.
Browser WebSocket does not support arbitrary request headers. If `/acp` is mounted behind the same auth middleware, direct renderer WebSocket may fail.
Chosen direction: `/acp` should have ACP-compatible token auth.
During migration:
```text
REST routes:
X-Secret-Key header
ACP route:
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
After REST is removed:
```text
ACP only:
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
This preserves a security boundary for the long-term desktop API while still allowing direct renderer WebSocket connections.
Guardrails:
- Use a random ACP token for the `goosed` process.
- Prefer an ACP-specific token over reusing the raw REST `X-Secret-Key`.
- Never log full `/acp?token=...` URLs.
- Keep accepting `X-Secret-Key` only for REST during migration.
- Accept ACP token auth only for the local desktop backend.
- Keep REST and ACP as separate route branches so auth policy and endpoint removal stay clear.
Alternatives considered:
1. Mount `/acp` outside `X-Secret-Key` auth, relying on localhost binding.
2. Allow auth through a query parameter for `/acp`, for example `/acp?token=...`.
3. Open the WebSocket from Electron main, where headers are easier, and bridge to renderer via IPC.
4. Use HTTP/SSE transport where headers are possible, though this is less ergonomic for ACP permission/request-response flow.
Option 2 is the preferred approach. Option 1 matches current `ui/goose2` behavior, but it is weaker as the final desktop backend shape.
## Migration Routing
Keep REST and ACP side-by-side only for feature areas that have not moved yet.
Example shape:
```ts
const backend = {
sessions: flags.acpSessions ? acpSessions : restSessions,
chat: flags.acpChat ? acpChat : restChat,
providers: flags.acpProviders ? acpProviders : restProviders,
};
```
Rules:
- `goosed` remains default for unmigrated surfaces.
- ACP is opt-in per feature area.
- New functionality should prefer ACP unless blocked.
- Once a feature area is migrated to `/acp`, remove the corresponding REST endpoint from `goosed` rather than keeping a permanent fallback.
- Any missing behavior discovered during migration should be added to ACP before removing the REST endpoint.
- Final milestone is no runtime dependency on generated REST APIs.
## Migration Order
### 1. Mount `/acp` in `goosed`
Add ACP Axum router to the `goosed agent` app.
Likely places:
- `crates/goose-server/src/commands/agent.rs`
- `crates/goose-server/src/routes/mod.rs`
Use:
```rust
goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}
goose::acp::transport::create_router
```
Need to decide:
- route merge order
- whether ACP MCP app proxy should be included once or deduplicated against existing goosed MCP app proxy routes
Auth model: use token-authenticated `/acp` for direct renderer WebSocket, separate from REST `X-Secret-Key`.
### 2. Add desktop ACP client
Add something like:
```text
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
```
This can be based on `ui/goose2`.
### 3. Migrate session list/create/load
Map:
```text
REST /agent/start -> ACP session/new
REST /agent/resume -> ACP session/load
REST /sessions -> ACP session/list
REST /sessions/{id} -> ACP session/load plus replay/session metadata
```
This proves:
- session IDs
- history replay
- session metadata
- current model/provider/mode state
### 4. Migrate chat streaming
Map:
```text
REST /sessions/{id}/reply
REST /sessions/{id}/events
-> ACP session/prompt + session/update notifications
```
Prefer moving the chat state toward ACP-native events and data structures rather than preserving the old goosed `MessageEvent` model. A temporary adapter into existing desktop `Message` and `TokenState` shapes is acceptable only as a short bridge if it substantially lowers rollout risk.
### 5. Migrate tool approval and tool display
Map:
```text
REST /action-required/tool-confirmation
-> ACP RequestPermissionRequest response
```
Tool display should move toward ACP-native `tool_call` / `tool_call_update` state. Any old desktop message-shape adapter should be treated as temporary migration glue.
### 6. Migrate provider/model/mode
Use ACP session config options:
```text
setSessionConfigOption({ configId: "provider" })
setSessionConfigOption({ configId: "model" })
setSessionConfigOption({ configId: "mode" })
```
Use existing Goose custom methods for provider inventory and setup:
```text
_goose/providers/list
_goose/providers/config/read
_goose/providers/config/save
_goose/providers/config/status
_goose/providers/custom/*
_goose/providers/catalog/*
```
### 7. Migrate extensions/tools/resources
Existing custom ACP methods cover:
```text
_goose/extensions/add
_goose/extensions/remove
_goose/config/extensions
_goose/config/extensions/add
_goose/config/extensions/remove
_goose/config/extensions/toggle
_goose/session/extensions
_goose/tools
_goose/tool/call
_goose/resource/read
_goose/working_dir/update
```
### 8. Migrate settings and secondary surfaces
Existing ACP custom methods cover many settings/product surfaces:
```text
_goose/preferences/*
_goose/defaults/*
_goose/onboarding/import/*
_goose/sources/*
_goose/dictation/*
```
## First Spike Plan: Configured Extensions List
Before the ACP session-list PR lands, use configured extensions as the first migration slice. This avoids session ID semantics and chat streaming complexity while still proving the core `/acp` path.
### Scope
Migrate the read-only configured extensions list behind a feature flag.
```text
Current REST:
GET /config/extensions
Target ACP:
_goose/config/extensions
```
Do not migrate add/update/delete in this first slice. Those can remain REST until the read path and ACP client plumbing are proven.
### Why This Is A Good First Slice
- No session ID dependency.
- No chat streaming or prompt lifecycle.
- No tool approval.
- No message-shape conversion.
- Exercises direct renderer WebSocket to `/acp`.
- Exercises ACP initialization and custom `_goose/*` request dispatch.
- Easy to compare REST and ACP responses.
### Current Gap Found
REST `GET /config/extensions` filters hidden extensions:
```rust
goose::config::get_all_extensions()
.into_iter()
.filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name()))
```
ACP `_goose/config/extensions` currently calls `crate::config::extensions::get_all_extensions()` and does not apply the same hidden-extension filter.
For this spike, either:
1. Fix ACP to match REST before enabling the feature flag, or
2. Allow the mismatch only in local spike mode and track it as the first ACP gap.
Recommendation: fix ACP to match REST. The goal is to prove migration, not introduce UI-visible behavior differences.
### Backend Steps
1. Mount `/acp` in `goosed agent`.
- Add or merge the ACP Axum router into `crates/goose-server`.
- Keep REST and ACP as separate route branches.
- Avoid collisions with existing `/status`, `/mcp-app-proxy`, and `/mcp-app-guest` routes.
2. Add ACP token auth for `/acp`.
- Direct renderer WebSocket should connect with:
```text
ws(s)://127.0.0.1:<port>/acp?token=<acp-token>
```
- Do not put `/acp` behind REST `X-Secret-Key` middleware.
- Do not log full token-bearing URLs.
3. Fix `_goose/config/extensions` parity.
- Apply the same hidden-extension filtering as REST.
- Preserve `warnings`.
- Preserve enough shape for existing desktop extension rendering.
### Desktop Client Steps
1. Add ACP client files under `ui/desktop/src/acp/`.
Suggested files:
```text
ui/desktop/src/acp/createWebSocketStream.ts
ui/desktop/src/acp/acpConnection.ts
ui/desktop/src/acp/acpApi.ts
```
2. Base these on `ui/goose2`, but copy/adapt rather than share.
- Replace Tauri URL lookup with Electron lookup.
- Build ACP URL from the existing goosed host.
- Append the ACP token.
- Initialize ACP once and reconnect on close.
3. Add an ACP extensions API wrapper:
```ts
getConfigExtensionsViaAcp(): Promise<ExtensionResponse>
```
It should call:
```text
_goose/config/extensions
```
and normalize the response to the existing desktop `ExtensionResponse` shape.
4. Add a feature flag.
Suggested name:
```text
acpConfigExtensions
```
5. Route only the read path through ACP when the flag is enabled.
Primary integration point:
- `ui/desktop/src/components/ConfigContext.tsx`
Keep mutation paths on REST for this first spike:
- `addExtension`
- `removeExtension`
- `toggleExtension`
- bundled extension sync/prune
### Validation
Compare REST and ACP for the same local config:
- same visible extension count
- same names
- same `enabled` values
- same extension types (`builtin`, `stdio`, `streamable_http`, etc.)
- same warnings
- hidden extensions do not appear
- settings/extensions UI renders correctly
- add/update/delete still work through REST and refresh the list
- failed ACP connection falls back to REST while the spike flag is enabled
### Removal Rule
Do not remove `GET /config/extensions` after this first read-only spike if writes still use REST sync logic that depends on the endpoint.
Remove the REST extension endpoints only after the full extension config surface has migrated:
```text
GET /config/extensions
POST /config/extensions
DELETE /config/extensions/{name}
```
Corresponding ACP methods:
```text
_goose/config/extensions
_goose/config/extensions/add
_goose/config/extensions/remove
_goose/config/extensions/toggle
```
## Known Gaps To Investigate
Likely REST-only or partially covered areas:
- recipe encode/decode/scan/schedule/create-from-session
- schedules
- local inference model management/downloads
- tunnel/gateway
- diagnostics/system info
- telemetry
- session sharing
- app export/import/list app flows
- MCP UI proxy details
- current `ActiveRequests` reattach semantics
During the transition these can remain on REST fallback. The final target is to expose each required capability through Goose custom ACP methods under `_goose/...` unless it maps cleanly to standard ACP.
## Recommended End State
Short term:
```text
goosed exposes REST + temporary /acp
desktop uses REST by default, ACP by feature flag
```
Migration:
```text
desktop moves one feature area at a time to ACP
missing backend behavior is added as standard ACP use or _goose custom methods
matching goosed REST endpoints are removed as each feature migrates
```
End state:
```text
desktop talks to /acp directly
goose serve is the single bundled desktop backend
goosed is no longer bundled or spawned
REST/OpenAPI is removed from desktop runtime behavior
```
## Open Decisions
No major architecture decisions remain from this spike. Implementation details still need validation around route merge order, MCP app proxy deduplication, and exact token plumbing.
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/code-review
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/create-pr
@@ -1 +0,0 @@
../../.agents/skills/edge-case-finder
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/code-review
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/create-pr
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/edge-case-finder
-48
View File
@@ -1,48 +0,0 @@
# Dependencies
node_modules/
# Build output
dist/
# Rust/Tauri build artifacts
/target/
src-tauri/target/
# Environment files
.env
.env.*
!.env.example
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
.*.sw?
# OS artifacts
.DS_Store
Thumbs.db
# Scratch / working files
.scratch/
# Playwright artifacts
playwright-report/
test-results/
# App E2E screenshots
tests/app-e2e/screenshots/
# Testing coverage
coverage/
# Logs
*.log
# Hermit (toolchain manager cache)
.hermit/
# Claude
.claude
-275
View File
@@ -1,275 +0,0 @@
# AGENTS.md
Guidelines for AI agents (and developers) working on this codebase.
## Project Overview
Goose2 is a Tauri 2 + React 19 desktop app. It uses TypeScript strict mode, Vite, and Tailwind CSS 4. The codebase follows a feature-sliced architecture organized under `src/app/`, `src/features/`, and `src/shared/`.
## First Steps
Treat this repo as partially Hermit-managed. Do not assume `just`, `pnpm`, `node`, or `lefthook` are available globally.
- In bash/zsh, run `source ./bin/activate-hermit` before using repo tools if the shell cannot find `just`, `pnpm`, or other managed binaries.
- In fish, run `source ./bin/activate-hermit.fish`.
- If PATH still looks wrong or you want to avoid shell assumptions, prefer repo-local binaries such as `./bin/just`, `./bin/pnpm`, and `./bin/lefthook`.
- Biome is installed from `package.json` devDependencies, not from Hermit. Run it through `pnpm`, `pnpm exec biome`, or `npx biome` after `just setup`.
- On a fresh clone, a newly created worktree, or after `just clean`, run `just setup` before relying on `pnpm`, Biome, or app-local tooling.
- In new clones and worktrees, ensure git hooks are installed early with `lefthook install`. If `lefthook` is not on PATH, use `./bin/lefthook install`.
- Agents starting in a fresh clone or worktree should do the setup and hook-install steps proactively rather than assuming the environment is already bootstrapped.
- Use `just dev` for the normal desktop workflow. Use `just dev-frontend` only when you intentionally want the Vite app without Tauri.
## Common Commands
- `just setup` installs frontend dependencies with `pnpm install` and builds the Rust backend once.
- `just dev` starts the desktop app in dev mode and wires Tauri to the local Vite server.
- `just check` runs Biome checks and file-size checks.
- `just test` runs the Vitest suite.
- `just tauri-check` runs `cargo check` in `src-tauri`.
- `just ci` is the main local verification gate.
- `just clean` removes Rust build artifacts, `dist`, and `node_modules`, so `just setup` is required again before `just dev`.
## Architecture
### The frontend → ACP → goose core path
**All frontend ↔ backend communication in goose2 flows through a single path:**
```
React UI ──► features/<feature>/api/ ──► @aaif/goose-sdk (TS) ──► goose-acp (WebSocket, ACP) ──► goose (core)
```
**YOU MUST TREAT THE CLIENT as a THIN CLIENT****
- The Tauri shell spawns a long-lived `goose serve` process and exposes its WebSocket URL via the `get_goose_serve_url` Tauri command. That is essentially the only Tauri command the frontend needs for backend work — it is how the renderer discovers the ACP endpoint.
- The frontend opens a WebSocket to `goose serve` and talks to it using `@aaif/goose-sdk` (published from `ui/sdk/`). The SDK is generated from the ACP custom-method definitions in `crates/goose-sdk/src/custom_requests.rs`, so every backend method has a typed TypeScript client method.
- The goose2 TypeScript code should only be UI
- `goose-acp` (`crates/goose-acp/src/server.rs`) is the server side of the WebSocket. It implements handlers for the custom ACP methods and calls into the `goose` core crate to do the actual work (providers, config, sessions, dictation, etc.).
- `goose` is the pure domain crate. It knows nothing about Tauri or WebSockets — it just exposes Rust APIs that `goose-acp` handlers invoke.
**This is the pattern you must follow when adding any new backend-touching feature.** When you are vibecoding in this app, it is very tempting to reach for `invoke()` or add an HTTP fetch — don't. The rule is: if a feature needs to talk to `goose` core, it goes through the SDK → ACP → goose chain above.
### Don't build features entirely in the frontend
If a feature involves data, persistence, secrets, provider config, sessions, filesystem access, network calls to external services, or anything else that could plausibly be reused by the CLI or another goose surface, **the logic belongs in the `goose` core crate, exposed via a typed ACP method.** Do not:
- Stand up a feature whose business logic lives in a Zustand store, a React hook, or a `features/<feature>/api/` adapter that calls `localStorage`, `fetch`, or filesystem APIs directly.
- Reach for `invoke()` to add a new Tauri command that proxies into `goose` — add an ACP custom method instead.
- Do not use `localStorage` except for things it's absolutely needed for (specifically outlined in other parts of this AGENTS.md)
- Duplicate types between TS and Rust — let the SDK generation produce them from `crates/goose-sdk/src/custom_requests.rs` and use the generated types ALWAYS. Do not create a shadow of a type you could use from the generated types.
The frontend's job is presentation, navigation, and orchestration of typed SDK calls. If you find a feature growing real logic on the React side, that's a signal to push it down into `goose-acp` + `goose`.
### File structure
The directory layout is organized to reinforce the path above. Every feature that touches the backend has an `api/` module that wraps `GooseClient` calls — UI and stores never touch the SDK directly.
```
src/
app/ — App shell, entry point, top-level providers
features/ — Feature modules (see Feature Organization below)
<feature>/
ui/ — React components (required)
hooks/ — Custom React hooks for feature logic (when needed)
stores/ — Zustand state management (frontend-only UI state; not a substitute for goose core)
api/ — Thin wrappers around GooseClient SDK calls (the ONLY place ACP is touched)
types.ts — Feature-specific type definitions (when needed)
shared/
ui/ — Reusable UI components (button, etc.)
lib/ — Utilities (cn.ts for class merging)
theme/ — Theme provider, appearance settings
styles/ — Global CSS, design tokens
hooks/ — Shared hooks
api/ — Shared GooseClient wrappers used by multiple features (e.g. dictation)
constants/ — Shared constants
context/ — Shared contexts
```
### Feature Organization
Not every feature needs every subdirectory. Use only what the feature requires. Note that anything beyond `ui/` only — i.e. anything with state or backend calls — should already have a corresponding ACP method on the goose side.
| Pattern | Structure | Examples | Backend shape |
|----------------------|----------------------------------|---------------------------------|------------------------------------------------|
| **Full-featured** | `stores/` + `hooks/` + `ui/` + `api/` | agents, chat | Typed ACP methods drive the store |
| **Data-driven** | `stores/` + `api/` + `ui/` | projects | CRUD via ACP, store caches results |
| **API features** | `api/` + `ui/` | skills, providers | Pure pass-through to ACP |
| **Simple features** | `ui/` only | home, settings, sidebar, status | No backend (pure presentation) |
| **Tabs** | `ui/` + `types.ts` | tabs | No backend (frontend-only UI state) |
If you're tempted to build a "Full-featured" or "Data-driven" feature without a corresponding `api/` module that calls a typed ACP method, stop and add the ACP method first. See "The canonical example" below.
### Import Rules for Features
- Shared types live in `src/shared/types/` — this is the single source of truth for cross-feature types.
- There should be NO root-level `src/stores/` or `src/types/` directories.
- Feature stores use feature-relative imports (e.g., `../stores/featureStore`).
- Cross-feature imports use `@/features/*/stores/` or `@/shared/types/`.
- Only `features/<feature>/api/` and `shared/api/` modules may import from `@aaif/goose-sdk` or call `getClient()`. UI components, hooks, and stores must go through those wrappers.
## Coding Conventions
- Use `cn()` from `@/shared/lib/cn` for Tailwind class merging.
- Import paths use the `@/` alias (maps to `./src`).
- Components are controlled where possible (state lifted to parent).
- Use `@tabler/icons-react` for icons (transitioning from `lucide-react`; existing `lucide-react` usage is fine until migrated).
- All `<button>` elements must have `type="button"` to prevent form submission.
- Use semantic HTML (`<aside>`, `<nav>`, `<header>`, `<main>`).
## Localization
- UI copy should go through `react-i18next`, not hardcoded English strings, for app areas that are already on i18n.
- Shared localization lives in `src/shared/i18n/`; use `useTranslation()` for text and the helpers in `src/shared/i18n/format.ts` for dates, times, numbers, currency, and relative time.
- Keep translations in feature-scoped JSON namespaces under `src/shared/i18n/locales/<locale>/` instead of one large file, and use stable keys rather than English sentences as keys.
- Do not translate user-authored content, agent/model output, or backend-only strings unless they are rendered directly as Goose UI.
- `pnpm check` includes `check:i18n`, which flags obvious new raw UI strings in migrated surfaces. Use a narrow `i18n-check-ignore` comment only when the string should stay literal.
## Theming System
ThemeProvider manages three axes:
| Axis | Values | Persistence | Mechanism |
|--------------|---------------------------------|-----------------|----------------------------------------------|
| Theme mode | `light`, `dark`, `system` | localStorage | `.dark` class on `<html>` |
| Accent color | Hex color | localStorage | `--brand` / `--color-brand` CSS variables |
| Density | `compact`, `comfortable`, `spacious` | localStorage | `--density-spacing` CSS variable (0.75/1/1.25) |
- CSS variables are defined in `globals.css` with light/dark variants.
- Tailwind config maps CSS variables to semantic color names.
- Color palette tokens: `background` (primary/secondary/tertiary), `foreground` (primary/secondary/tertiary), `border`, `ring`, plus semantic variants (`info`, `danger`, `success`, `warning`).
## Component Patterns
- Small, focused components — aim for under 200 lines.
- Props interfaces live in the component file, or in `types.ts` for shared types.
- Use `forwardRef` for components that need ref forwarding (React 19 makes this optional, but the pattern is still used).
- Animations: CSS transitions via Tailwind classes; respect `prefers-reduced-motion`.
- Entrance animations: use the `isLoaded` state pattern with `useEffect` + short timeout.
## Accessibility
- ARIA roles on interactive elements (`role="tab"`, `role="tablist"`, `role="status"`).
- `aria-label` on icon-only buttons.
- `aria-hidden` on visually hidden content.
- `aria-selected` on selectable items.
- Color-only indicators must have text alternatives.
- `prefers-reduced-motion` is respected globally.
## Tauri Integration
- The window starts hidden and is shown via `getCurrentWindow().show()` after React mounts.
- Use `data-tauri-drag-region` on header areas for window dragging.
- Title bar uses `titleBarStyle: "Overlay"` with `hiddenTitle: true` for a custom titlebar.
- `tauri-plugin-window-state` persists window size and position.
- Traffic light offset: `pl-20` (80px) to accommodate macOS window controls.
- Distro bundle behavior, including feature flags, is documented in `distro/README.md`.
## Working in the ACP path: examples and rules
### The canonical example: skills-as-sources (PR #8675)
The skills → sources migration in [#8675](https://github.com/block/goose/pull/8675) is the clearest illustration of the rule. **It deleted 319 lines of Tauri-command code in `src-tauri/src/commands/skills.rs` and replaced them with ACP custom methods.** If you find yourself wanting to add an `invoke()` command that proxies to `goose`, that PR is what "doing it the other way" looks like. Copy this shape when adding new endpoints:
1. **Define the request/response in `crates/goose-sdk/src/custom_requests.rs`.** Use the `JsonRpcRequest` / `JsonRpcResponse` derives and the `#[request(method = "_goose/<area>/<action>", response = ...)]` attribute. Sources uses namespaced methods like `_goose/sources/create`, `_goose/sources/list`, `_goose/sources/update`, `_goose/sources/delete`, `_goose/sources/export`, `_goose/sources/import` with paired request/response structs (`CreateSourceRequest` / `CreateSourceResponse`, etc.). Keep the docs on those structs aligned with the implementation: today `_goose/sources/list` is still skill-only; create/import take an explicit target scope (`global`, plus `projectDir` for project sources), while update/delete/export operate on an existing skill by absolute `path`.
2. **Implement the handler in `crates/goose-acp/src/server.rs`** with `#[custom_method(YourRequest)]`. Keep it thin: unpack the request, call into the `goose` crate, wrap the result. The sources handlers are ~5 lines each — e.g. `on_list_sources` just calls `goose::sources::list_sources(...)` and returns the typed response. Errors map to `agent_client_protocol::Error::invalid_params()` / `internal_error()`.
3. **Put the real logic in the `goose` crate.** Sources lives in `crates/goose/src/sources.rs` — filesystem CRUD, frontmatter parsing, scope resolution, all of it. `goose-acp` knows nothing about where skills are stored on disk; it just forwards typed arguments. This separation is the point.
4. **Regenerate the SDK.** The TS methods on `GooseClient` are generated into `ui/sdk/src/generated/`. Do not hand-edit generated files.
5. **Call it from the frontend via a feature `api/` module.** See `ui/goose2/src/features/skills/api/skills.ts`. It calls `getClient()` from `acpConnection.ts` and invokes the SDK, then adapts the generic `SourceEntry` shape into a feature-friendly `SkillInfo`:
```ts
export async function listSkills(): Promise<SkillInfo[]> {
const client = await getClient();
const raw = await client.extMethod("_goose/sources/list", { type: "skill" });
const sources = (raw.sources ?? []) as SourceEntry[];
return sources.map(toSkillInfo);
}
```
Feature code (hooks, stores, UI) imports from that `api/` module — it never touches the ACP client directly.
**Note on typed vs untyped calls.** Skills currently uses `client.extMethod("_goose/sources/...", ...)` (the untyped escape hatch) because it reshapes a generic `Source` API into skill-specific types. The **preferred** shape for new features is the typed generated methods — `client.goose.GooseFooBar({ ... })` — as used by dictation (`client.goose.GooseDictationTranscribe`) and the provider inventory (`client.goose.GooseProvidersList`). Reach for `extMethod()` only when you have a real reason to bypass the generated types.
For a minimal frontend `api/` wrapper using the typed shape, see `ui/goose2/src/features/providers/api/inventory.ts` — ~30 lines, typed SDK calls, thin adapter. For a fully worked end-to-end feature including OS-keychain handling and progress streaming, see the voice dictation feature ([#8609](https://github.com/block/goose/pull/8609)) and `ui/goose2/src/shared/api/dictation.ts`.
### Typed ACP config contracts
Build goose2 config flows around typed ACP methods whose contract matches the domain: provider config, preferences, defaults, dictation secrets, or extension config.
Keep raw Goose storage keys and secret-handling decisions behind backend-owned ACP methods. This lets Goose validate inputs, apply provider metadata, invalidate caches, refresh dependent state, and keep generated SDK types aligned with supported behavior.
For reference, define contracts in `crates/goose-sdk/src/custom_requests.rs`, implement them in `crates/goose/src/acp/server/`, regenerate `ui/sdk/src/generated/`, then call the generated `client.goose.*` methods from a feature or shared `api/` wrapper.
### When `invoke()` is still appropriate
Tauri commands (`invoke()` from `@tauri-apps/api/core`) are reserved for things that genuinely belong to the desktop shell, not to `goose` core. Provider config or secret mutations that affect the Goose runtime must flow through React → SDK → ACP → goose core so core can validate provider metadata, invalidate secret caches, refresh inventory, and apply provider changes consistently. In practice, Tauri is limited to:
- `get_goose_serve_url` — bootstrapping the ACP connection.
- Native auth subprocesses and desktop-shell side effects.
- Window state, filesystem dialogs, and other Tauri-plugin-backed capabilities.
- Transitional provider cleanup such as `delete_provider_config` while local OAuth/cache side effects still live in the shell. `get_provider_config`, `check_all_provider_status`, and provider deletion duplicate provider/config knowledge in Tauri today and should move behind provider-scoped ACP methods.
The long-term provider-config API should be provider-scoped rather than a frontend composition of generic config/secret writes. Prefer methods such as `_goose/providers/config/read`, `_goose/providers/config/status`, `_goose/providers/config/save`, and `_goose/providers/config/delete`; inventory refresh can be part of the result, but active provider reload belongs in the config mutation/apply path, not in `_goose/providers/inventory/refresh`.
If the thing you're building is "get data from goose" or "tell goose to do something," it is **not** one of these cases. Add a custom ACP method instead.
### Don't
- Don't add HTTP `fetch` calls to a `goose` HTTP API, or reintroduce an `apiFetch` utility. There is no HTTP API for goose2 — the backend is the ACP WebSocket.
- Don't manage a sidecar `goose` process from the renderer. The Tauri shell owns that lifecycle.
- Don't add a new `invoke()` command in `src-tauri/` as a proxy to `goose` core. Add an ACP custom method instead.
- Don't hand-edit `ui/sdk/src/generated/`. Regenerate.
- Don't call the ACP client (`getClient()`) directly from UI components or stores. Go through a `shared/api/*.ts` (or `features/<feature>/api/*.ts`) module so the SDK surface is mockable in tests.
## Tooling
| Tool | Purpose |
|-------------|-------------------------------------------------|
| **Hermit** | Manages repo binaries such as `node`, `pnpm`, `just`, and `lefthook` |
| **Just** | Task runner (`just dev`, `just build`, `just check`) |
| **Lefthook**| Git hooks (pre-commit, pre-push) |
| **Biome** | Linting and formatting |
| **pnpm** | Package manager |
Additional tooling notes:
- Prefer repo-managed binaries over global tools when there is any ambiguity about PATH.
- Hermit manages `node`, `pnpm`, `just`, and `lefthook`, while Biome comes from `node_modules` after `just setup`.
- Tauri backend commands still rely on a working Rust/Cargo toolchain.
- Pre-commit hooks run formatting plus `just check`.
- Pre-push hooks run `just fmt-check`, `just clippy`, `just check`, `just test`, `just build`, and `just tauri-check`.
- Do not use `--no-verify` to bypass hooks. Fix the underlying issue instead.
## Performance Logging
- Frontend perf logs use `perfLog()` from `@/shared/lib/perfLog`. Messages are tagged `[perf:<channel>]` (startup, conn, load, newtab, prepare, send, api, stream, replay, chatview). Enabled automatically in Vite dev mode, or opt-in via `localStorage.setItem("goose.perf", "1")` in a release build.
- Backend perf logs live in `crates/goose-acp/src/server.rs` under `target: "perf"` at `debug!` level. Off by default; enable with `RUST_LOG=perf=debug,info` on the `goose serve` process.
- `just dev` and `just dev-debug` export `RUST_LOG=perf=debug,info` so the child `goose serve` emits perf logs without extra setup. Override by setting `RUST_LOG` in the environment before invoking `just`.
## Testing & Verification
- Unit/component tests use Vitest and Testing Library via `just test` or `pnpm test`.
- E2E tests use Playwright via `just test-e2e` and `just test-e2e-all`.
- Before handing off a change, run the smallest relevant verification step. Use `just ci` when you need the full local gate.
- GitHub Actions also runs desktop-oriented checks, including Playwright coverage, that are broader than the local pre-push hook.
## Key Dependencies
- `react` 19.1, `react-dom` 19.1
- `@tauri-apps/api` 2.x
- `@tanstack/react-query` 5.x
- `tailwindcss` 3.x with `tailwindcss-animate`
- `@tabler/icons-react` for icons (migrating from `lucide-react`)
- `class-variance-authority` for component variants
- `clsx` + `tailwind-merge` for class merging
- `@radix-ui/react-slot` for polymorphic components
## Don'ts
- Don't import from `../` across feature boundaries — use `@/` paths.
- Don't put business logic in UI components — extract to hooks or utilities.
- Don't use inline styles except for dynamic values (like animation delays).
- Don't add dependencies without checking if an existing one covers the need.
- Don't skip `type="button"` on buttons.
- Don't use color-only indicators without text alternatives.
- Never use `--no-verify` when pushing — fix the underlying lint/hook issues.
- Don't create root-level `src/types/` or `src/stores/` directories — types belong in `src/shared/types/`, stores belong in `src/features/<feature>/stores/`.
- Don't duplicate type definitions across files — each type has one canonical location.
- Don't build features end-to-end in the frontend. If it talks to data, secrets, providers, sessions, or the filesystem, it goes through `goose` core via a typed ACP method. See **Architecture → Don't build features entirely in the frontend**.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-30
View File
@@ -1,30 +0,0 @@
# Goose2
Goose2 is a Tauri 2 + React 19 desktop app.
## Getting Started
1. If your shell cannot find `just`, `pnpm`, or `lefthook`, activate Hermit.
bash/zsh: `source ./bin/activate-hermit`
fish: `source ./bin/activate-hermit.fish`
2. Install git hooks: `lefthook install`
3. Prepare workspace dependencies: `just setup`
4. Start the app: `just dev`
`just clean` removes Rust build artifacts, `dist`, and `node_modules`. Run `just setup` again before `just dev`.
`just setup` installs UI workspace dependencies, builds the SDK package, and builds the local debug `goose` CLI binary. `just dev` exports `GOOSE_BIN` to that local binary and loads `src-tauri/tauri.dev.conf.json`, which clears the production `externalBin` requirement during development.
Run `just` to list available commands, or see [justfile](./justfile) for the full recipe definitions.
## Important Files
- [AGENTS.md](./AGENTS.md) repo conventions and agent guidance
- [justfile](./justfile) local setup, dev, test, and CI commands
- [CODEOWNERS](./CODEOWNERS) code ownership
- [.github/workflows/ci.yml](./.github/workflows/ci.yml) CI checks
- [.github/ISSUE_TEMPLATE/](./.github/ISSUE_TEMPLATE/) issue templates
- [GOVERNANCE.md](./GOVERNANCE.md) project governance
- [LICENSE](./LICENSE) license terms
Project leads should keep this README, [CODEOWNERS](./CODEOWNERS), and the issue templates current. If this repo grows beyond the quick-start flow above, add a `CONTRIBUTING.md` and link it here once it exists.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

-73
View File
@@ -1,73 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!src-tauri/gen",
"!src-tauri/plugins/*/permissions/{schemas,autogenerated}",
"!.agents",
"!.worktrees",
"!.claude/worktrees",
"!justfile"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
}
}
},
"assist": {
"enabled": false
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [
{
"includes": ["src/shared/styles/globals.css"],
"linter": {
"rules": {
"complexity": { "noImportantStyles": "off" }
}
}
},
{
"includes": ["src/shared/ui/**", "src/components/ai-elements/**"],
"linter": {
"rules": {
"a11y": {
"useSemanticElements": "off",
"useFocusableInteractive": "off",
"useKeyWithClickEvents": "off",
"useAriaPropsForRole": "off",
"noRedundantRoles": "off"
},
"correctness": { "useExhaustiveDependencies": "off" },
"suspicious": { "noArrayIndexKey": "off", "noDocumentCookie": "off" },
"security": { "noDangerouslySetInnerHtml": "off" }
}
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "ghost",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/shared/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared/ui",
"utils": "@/shared/lib/cn",
"ui": "@/shared/ui",
"lib": "@/shared/lib"
},
"registryUrl": "https://block.github.io/ghost/r/registry.json",
"iconLibrary": "lucide"
}
-101
View File
@@ -1,101 +0,0 @@
# Goose2 distro bundles
A Goose2 distro bundle is an optional app-specific package of configuration and policy that the Tauri shell loads at startup.
## What a distro bundle is
A distro bundle lives under `ui/goose2/distro/` in development, and is bundled into the packaged app as a Tauri resource in production.
Current supported files:
- `distro.json` — distro manifest
- `config.yaml` — optional Goose config passed to `goose serve`
- `bin/` — optional executables or helper scripts prepended to `PATH` for `goose serve`
## How it is discovered
The Tauri app resolves the distro bundle in this order:
1. `GOOSE_DISTRO_DIR`, if set
2. bundled Tauri resource dir at `resource_dir()/distro`
In development, `just dev` and `just dev-debug` automatically export `GOOSE_DISTRO_DIR` to `ui/goose2/distro` when that directory exists.
## Manifest shape
Example:
```json
{
"appVersion": "development",
"featureToggles": {
"costTracking": false
},
"providerAllowlist": "databricks"
}
```
### Fields
- `appVersion?: string`
- optional app version tag supplied by the distro
- `featureToggles?: Record<string, boolean>`
- optional UI/product flags controlled by the distro
- currently supported:
- `costTracking`
- `false` hides cost UI in the token/context usage surfaces
- omitted behaves as enabled
- `providerAllowlist?: string`
- comma-separated provider ids
- suggests which model providers to show in Settings
- suggests which Goose model options to show in the chat model picker
- `extensionAllowlist?: string`
- comma-separated extension ids
- reserved for future UI suggestions
## Runtime effects
When a distro bundle is present, Goose2 does two kinds of things with it.
### Frontend behavior
The frontend loads `get_distro_bundle` during app startup and stores the manifest in Zustand.
Today it uses that manifest to:
- filter model providers shown in provider settings via `providerAllowlist`
- filter Goose model options shown in the chat input model picker via `providerAllowlist`
- hide cost UI when `featureToggles.costTracking === false`
These allowlists are UI suggestions only. They do not enforce backend access control and do not invalidate existing sessions or saved model choices.
### Backend / shell behavior
When the Tauri shell launches the long-lived `goose serve` process, it applies the distro bundle like this:
- prepends `distro/bin` to `PATH` when present
- adds `distro/config.yaml` to `GOOSE_ADDITIONAL_CONFIG_FILES` when present
- sets `GOOSE_DISTRO_DIR` to the resolved distro root
This is shell-level behavior, so it is implemented as Tauri-side setup rather than an ACP method.
## Development notes
- packaged apps discover distro content from bundled Tauri resources
- local development uses `GOOSE_DISTRO_DIR`
- after changing `distro.json`, restart `just dev` so startup reloads the manifest
## Scope guidance
Use distro bundles for packaged-app policy and shell-level defaults.
Good fits:
- feature flags for Goose2 UI behavior
- allowlists that suggest visible product choices
- config or helper binaries that should be present when `goose serve` starts
Avoid using distro bundles as a replacement for normal app state, user settings, or ACP-backed domain data.
View File
-5
View File
@@ -1,5 +0,0 @@
{
"featureToggles": {
"costTracking": true
}
}
-12
View File
@@ -1,12 +0,0 @@
import { defineConfig } from "@ghost/core";
export default defineConfig({
designSystems: [
{
name: "goose2",
registry: "https://block.github.io/ghost/r/registry.json",
componentDir: "src/shared/ui",
styleEntry: "src/shared/styles/globals.css",
},
],
});
-87
View File
@@ -1,87 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
(() => {
const getDefaultAccent = (theme) =>
theme === "dark" ? "#ffffff" : "#1a1a1a";
const normalizeHexColor = (color) => {
const value = color?.trim();
if (!value || value === "default") return null;
const hex = value.startsWith("#") ? value.slice(1) : value;
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
return `#${hex
.split("")
.map((char) => char + char)
.join("")
.toLowerCase()}`;
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
return null;
};
const getRelativeLuminance = (hexColor) => {
const hex = hexColor.slice(1);
const channels = [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)]
.map((channel) => {
const value = Number.parseInt(channel, 16) / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
};
const getContrastColor = (hexColor) => {
const luminance = getRelativeLuminance(hexColor);
const blackContrast = (luminance + 0.05) / 0.05;
const whiteContrast = 1.05 / (luminance + 0.05);
return blackContrast >= whiteContrast ? "#000000" : "#ffffff";
};
try {
const root = document.documentElement;
const storedTheme = localStorage.getItem("goose-theme") || "system";
const resolvedTheme =
storedTheme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: storedTheme;
const accent =
normalizeHexColor(localStorage.getItem("goose-accent-color")) ||
getDefaultAccent(resolvedTheme);
const foreground = getContrastColor(accent);
const density = localStorage.getItem("goose-density") || "comfortable";
root.classList.add(resolvedTheme === "dark" ? "dark" : "light");
root.style.colorScheme = resolvedTheme === "dark" ? "dark" : "light";
root.style.setProperty("--brand", accent);
root.style.setProperty("--brand-foreground", foreground);
root.style.setProperty("--color-brand", accent);
root.style.setProperty("--color-brand-foreground", foreground);
root.style.accentColor = accent;
if (density === "compact" || density === "spacious") {
root.dataset.density = density;
}
} catch {
// ThemeProvider applies the canonical theme state after React mounts.
}
})();
</script>
<title>Goose</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-285
View File
@@ -1,285 +0,0 @@
# Derive a stable default port from the working directory so the same worktree
# usually gets the same port. Set VITE_PORT to override it.
default_vite_port := `python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)"`
# Default recipe
default:
@just --list
# ── Dev Environment ──────────────────────────────────────────
# Install dependencies, build workspace packages, and activate git hooks
setup:
cd ../ && pnpm install
cd ../sdk && pnpm build
cargo build --manifest-path ../../Cargo.toml -p goose-cli --bin goose
# ── Build & Check ────────────────────────────────────────────
# Run all checks (lint, format, typecheck, file sizes)
check:
pnpm check
pnpm typecheck
# Format code
fmt:
pnpm format
cd src-tauri && cargo fmt
# Check formatting without modifying
fmt-check:
pnpm exec biome format .
cd src-tauri && cargo fmt --check
# Run clippy on Tauri backend
clippy:
cd src-tauri && TAURI_CONFIG='{"bundle":{"externalBin":[]}}' cargo clippy -- -D warnings
# Build the frontend
build:
pnpm build
# Check Tauri Rust formatting
tauri-fmt-check:
cd src-tauri && cargo fmt --check
# Check Tauri Rust types
tauri-check:
cd src-tauri && TAURI_CONFIG='{"bundle":{"externalBin":[]}}' cargo check
# Full CI gate
ci: check clippy test build tauri-check
bundle:
pnpm tauri build
# ── Test ─────────────────────────────────────────────────────
# Run unit/component tests
test:
pnpm test
# Run tests in watch mode
test-watch:
pnpm test:watch
# Run tests with coverage
test-coverage:
pnpm test:coverage
# Run E2E smoke tests (builds first)
test-e2e:
pnpm test:e2e:smoke
# Run all E2E tests (builds first)
test-e2e-all:
pnpm test:e2e
# ── Run ──────────────────────────────────────────────────────
# Start the desktop app in dev mode
dev: build-core
#!/usr/bin/env bash
set -euo pipefail
DEFAULT_VITE_PORT={{ default_vite_port }}
VITE_PORT="${VITE_PORT:-$DEFAULT_VITE_PORT}"
export VITE_PORT
# Enable perf logs in the child `goose serve` process by default.
# Override with e.g. RUST_LOG=info just dev to disable.
export RUST_LOG="${RUST_LOG:-perf=debug,info}"
PROJECT_DIR=$(pwd)
REPO_ROOT=$(cd ../.. && pwd)
DISTRO_DIR="${PROJECT_DIR}/distro"
if [[ -z "${GOOSE_DISTRO_DIR:-}" && -d "${DISTRO_DIR}" ]]; then
export GOOSE_DISTRO_DIR="${DISTRO_DIR}"
fi
LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose"
LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose"
if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}"
elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}"
else
unset GOOSE_BIN
fi
EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"exec ./scripts/start-dev-vite.sh ${VITE_PORT}\",\"cwd\":\"${PROJECT_DIR}\",\"wait\":false}}}")
if [[ -n "${GOOSE_BIN:-}" ]]; then
echo "Using local goose binary: ${GOOSE_BIN}"
else
echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH"
fi
if [[ -n "${GOOSE_DISTRO_DIR:-}" ]]; then
echo "Using distro dir: ${GOOSE_DISTRO_DIR}"
fi
# In worktrees, generate a labeled icon so you can tell instances apart
if git rev-parse --is-inside-work-tree &>/dev/null; then
GIT_DIR=$(git rev-parse --git-dir)
if [[ "$GIT_DIR" == *".git/worktrees/"* ]]; then
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
WORKTREE_LABEL="${BRANCH_NAME##*/}"
ICON_DIR="$(pwd)/src-tauri/target/dev-icons"
mkdir -p "$ICON_DIR"
DEV_ICON="$ICON_DIR/icon.icns"
if swift scripts/generate-dev-icon.swift src-tauri/icons/icon.icns "$DEV_ICON" "$WORKTREE_LABEL"; then
echo "🌳 Worktree: ${WORKTREE_LABEL}"
EXTRA_CONFIG_ARGS+=(--config "{\"bundle\":{\"icon\":[\"$DEV_ICON\"]}}")
fi
fi
fi
pnpm tauri dev --features app-test-driver --config src-tauri/tauri.dev.conf.json "${EXTRA_CONFIG_ARGS[@]}"
# Start the desktop app with dev config
dev-debug: build-core
#!/usr/bin/env bash
set -euo pipefail
DEFAULT_VITE_PORT={{ default_vite_port }}
VITE_PORT="${VITE_PORT:-$DEFAULT_VITE_PORT}"
export VITE_PORT
# Enable perf logs in the child `goose serve` process by default.
# Override with e.g. RUST_LOG=info just dev-debug to disable.
export RUST_LOG="${RUST_LOG:-perf=debug,info}"
PROJECT_DIR=$(pwd)
REPO_ROOT=$(cd ../.. && pwd)
DISTRO_DIR="$(pwd)/distro"
if [[ -z "${GOOSE_DISTRO_DIR:-}" && -d "${DISTRO_DIR}" ]]; then
export GOOSE_DISTRO_DIR="${DISTRO_DIR}"
fi
LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose"
LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose"
if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}"
elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then
export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}"
else
unset GOOSE_BIN
fi
EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"exec ./scripts/start-dev-vite.sh ${VITE_PORT}\",\"cwd\":\"${PROJECT_DIR}\",\"wait\":false}}}")
if [[ -n "${GOOSE_BIN:-}" ]]; then
echo "Using local goose binary: ${GOOSE_BIN}"
else
echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH"
fi
if [[ -n "${GOOSE_DISTRO_DIR:-}" ]]; then
echo "Using distro dir: ${GOOSE_DISTRO_DIR}"
fi
# In worktrees, generate a labeled icon so you can tell instances apart
if git rev-parse --is-inside-work-tree &>/dev/null; then
GIT_DIR=$(git rev-parse --git-dir)
if [[ "$GIT_DIR" == *".git/worktrees/"* ]]; then
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
WORKTREE_LABEL="${BRANCH_NAME##*/}"
ICON_DIR="$(pwd)/src-tauri/target/dev-icons"
mkdir -p "$ICON_DIR"
DEV_ICON="$ICON_DIR/icon.icns"
if swift scripts/generate-dev-icon.swift src-tauri/icons/icon.icns "$DEV_ICON" "$WORKTREE_LABEL"; then
echo "🌳 Worktree: ${WORKTREE_LABEL}"
EXTRA_CONFIG_ARGS+=(--config "{\"bundle\":{\"icon\":[\"$DEV_ICON\"]}}")
fi
fi
fi
pnpm tauri dev --config src-tauri/tauri.dev.conf.json "${EXTRA_CONFIG_ARGS[@]}"
# Start only the frontend dev server
dev-frontend:
pnpm dev
# Parse `git worktree list --porcelain` into tab-separated "path\tbranch" lines.
# Uses sub() instead of $2 so worktree paths containing spaces are preserved.
# Detached-HEAD worktrees (no branch line) are silently skipped.
_worktree_awk := '/^worktree / { sub(/^worktree /, ""); wt=$0 } /^branch refs\/heads\// { b=$2; sub(/^refs\/heads\//, "", b); print wt "\t" b }'
# Compute a stable vite port from a directory path passed as $1.
_port_cmd := "import hashlib,sys; h=int(hashlib.sha256(sys.argv[1].encode()).hexdigest(),16); print(10000+h%55000)"
# List worktrees with an active dev server
running:
#!/usr/bin/env bash
git worktree list --porcelain | awk '{{ _worktree_awk }}' \
| while IFS=$'\t' read -r wt branch; do
dir="$wt/ui/goose2"
port=$(python3 -c '{{ _port_cmd }}' "$dir")
if lsof -ti :"$port" &>/dev/null; then
echo "$branch"
fi
done
# Kill the dev process (if running). Optionally pass a branch name to kill another worktree's process.
kill branch="":
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "{{ branch }}" ]]; then
WORKTREE_PATH=$(git worktree list --porcelain \
| awk '{{ _worktree_awk }}' \
| awk -F'\t' -v branch="{{ branch }}" '$2 == branch { print $1; exit }')
if [[ -z "$WORKTREE_PATH" ]]; then
echo "No worktree found for branch '{{ branch }}'"
exit 1
fi
TARGET_DIR="$WORKTREE_PATH/ui/goose2"
VITE_PORT=$(python3 -c '{{ _port_cmd }}' "$TARGET_DIR")
else
DEFAULT_VITE_PORT={{ default_vite_port }}
VITE_PORT="${VITE_PORT:-$DEFAULT_VITE_PORT}"
fi
PID=$(lsof -ti :"$VITE_PORT" 2>/dev/null | head -1) || true
if [[ -z "$PID" ]]; then
echo "No process found on port $VITE_PORT"
exit 0
fi
PROC_NAME=$(ps -p "$PID" -o comm= 2>/dev/null) || true
PROC_BASENAME="${PROC_NAME##*/}"
if [[ "$PROC_BASENAME" != "node" ]]; then
echo "Process on port $VITE_PORT is '$PROC_NAME', not 'node' — refusing to kill"
exit 1
fi
PGID=$(ps -p "$PID" -o pgid= 2>/dev/null | tr -d ' ')
if [[ -z "$PGID" || "$PGID" == "0" || "$PGID" == "1" ]]; then
echo "Killing node (PID $PID) on port $VITE_PORT"
kill -9 "$PID"
else
echo "Killing process group $PGID (found via node PID $PID on port $VITE_PORT)"
kill -9 -"$PGID" 2>/dev/null || true
fi
# Kill all dev processes across all worktrees
kill-all:
#!/usr/bin/env bash
set -euo pipefail
branches=$(just running)
if [[ -z "$branches" ]]; then
echo "No running dev servers found"
exit 0
fi
while read -r branch; do
just kill "$branch" || true
done <<< "$branches"
# ── Utilities ────────────────────────────────────────────────
# Clean build artifacts
clean:
cd src-tauri && cargo clean
rm -rf dist
rm -rf node_modules
build-core:
cargo build -p goose-cli --bin goose
-130
View File
@@ -1,130 +0,0 @@
{
"name": "goose2",
"private": true,
"version": "0.20.1",
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"check:i18n": "node ./scripts/check-i18n-strings.mjs",
"lint": "biome lint .",
"check": "biome check . && pnpm check:i18n",
"format": "biome format --write .",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "pnpm build && playwright test",
"test:e2e:smoke": "pnpm build && playwright test --project=smoke",
"test:e2e:personas": "pnpm build && playwright test --project=personas",
"test:e2e:skills": "pnpm build && playwright test --project=skills",
"test:e2e:drafts": "pnpm build && playwright test --project=drafts",
"test:app-e2e": "vitest run --config vitest.app-e2e.config.ts",
"test-driver": "tsx tests/app-e2e/lib/test-driver-cli.ts"
},
"dependencies": {
"@aaif/goose-sdk": "workspace:*",
"@agentclientprotocol/sdk": "^0.19.0",
"@mcp-ui/client": "7.1.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.3",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tabler/icons-react": "^3.41.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-shell": "~2.3.5",
"@xyflow/react": "^12.10.2",
"ai": "^6.0.142",
"ansi-to-react": "^6.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"gsap": "^3.14.2",
"i18next": "^26.0.3",
"i18next-resources-to-backend": "^1.2.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.3",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.72.0",
"react-i18next": "^17.0.2",
"react-jsx-parser": "^2.4.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.8.0",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.8.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"sonner": "^2.0.7",
"split-type": "^0.3.4",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.3",
"vaul": "^1.1.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.2.2",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.6.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
"typescript": "~5.9.0",
"vite": "^7.0.4",
"vitest": "^4.1.0"
}
}
-58
View File
@@ -1,58 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const previewPort = 4173;
export default defineConfig({
testDir: "./tests/e2e",
timeout: 60_000,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
["list"],
["html", { open: "never", outputFolder: "playwright-report" }],
],
use: {
baseURL: `http://127.0.0.1:${previewPort}`,
screenshot: "only-on-failure",
trace: "on-first-retry",
video: "retain-on-failure",
},
projects: [
{
name: "smoke",
testMatch: ["**/smoke.spec.ts"],
use: {
...devices["Desktop Chrome"],
},
},
{
name: "personas",
testMatch: ["**/personas.spec.ts"],
use: {
...devices["Desktop Chrome"],
},
},
{
name: "skills",
testMatch: ["**/skills.spec.ts"],
use: {
...devices["Desktop Chrome"],
},
},
{
name: "drafts",
testMatch: ["**/drafts.spec.ts"],
use: {
...devices["Desktop Chrome"],
},
},
],
webServer: {
// Opt-in reuse only. Reusing arbitrary local processes makes the suite
// flaky when another test run or dev server happens to be bound here.
command: `python3 -m http.server ${previewPort} -d dist`,
cwd: ".",
reuseExistingServer: process.env.PLAYWRIGHT_REUSE_SERVER === "1",
url: `http://127.0.0.1:${previewPort}`,
},
});
-5
View File
@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
-3
View File
@@ -1,3 +0,0 @@
[toolchain]
channel = "1.94.1"
profile = "default"
-254
View File
@@ -1,254 +0,0 @@
import { readFileSync, readdirSync } from "node:fs";
import { extname, join, relative } from "node:path";
import ts from "typescript";
const CHECKED_PATHS = [
"src/app/ui",
"src/features/agents",
"src/features/chat/ui",
"src/features/home",
"src/features/projects",
"src/features/settings",
"src/features/skills",
"src/features/sidebar",
"src/features/status",
"src/features/sessions",
"src/shared/ui/ai-elements/code-block.tsx",
"src/shared/ui/ai-elements/environment-variables.tsx",
"src/shared/ui/ai-elements/message.tsx",
"src/shared/ui/ai-elements/plan.tsx",
"src/shared/ui/ai-elements/snippet.tsx",
"src/shared/ui/ai-elements/stack-trace.tsx",
"src/shared/ui/ai-elements/terminal.tsx",
"src/shared/ui/ai-elements/context.tsx",
"src/shared/ui/ai-elements/commit.tsx",
];
const EXCLUDED_PATH_SEGMENTS = ["__tests__"];
const EXCLUDED_FILE_MARKERS = [".test.", ".spec."];
const CHECKED_EXTENSIONS = new Set([".ts", ".tsx"]);
const TEXT_ATTRIBUTE_NAMES = new Set([
"aria-label",
"title",
"placeholder",
"alt",
]);
const TEXT_EXCLUDED_TAGS = new Set(["code", "pre", "kbd"]);
const IGNORE_COMMENT = "i18n-check-ignore";
function walkPath(targetPath) {
const entries = [];
let statEntries;
try {
statEntries = readdirSync(targetPath, { withFileTypes: true });
} catch {
if (CHECKED_EXTENSIONS.has(extname(targetPath))) {
return [targetPath];
}
return [];
}
for (const entry of statEntries) {
const fullPath = join(targetPath, entry.name);
if (entry.isDirectory()) {
entries.push(...walkPath(fullPath));
continue;
}
if (CHECKED_EXTENSIONS.has(extname(entry.name))) {
entries.push(fullPath);
}
}
return entries;
}
function isExcluded(filePath) {
const rel = relative(".", filePath);
const normalizedRel = rel.replace(/\\/g, "/");
const pathSegments = normalizedRel.split("/");
return (
EXCLUDED_PATH_SEGMENTS.some((segment) => pathSegments.includes(segment)) ||
EXCLUDED_FILE_MARKERS.some((marker) => normalizedRel.includes(marker))
);
}
function collapseWhitespace(text) {
return text.replace(/\s+/g, " ").trim();
}
function isProbablyUserFacingText(text) {
if (!text) return false;
if (!/\p{L}/u.test(text)) return false;
if (/^https?:\/\//.test(text)) return false;
if (/^[./~@#][\w./-]+$/.test(text)) return false;
if (/^[A-Z0-9_:-]+$/.test(text)) return false;
if (/^[\w.-]+\.[A-Za-z]{2,}$/.test(text)) return false;
return true;
}
function getLineText(sourceText, sourceFile, lineIndex) {
if (lineIndex < 0) return "";
const lineStarts = sourceFile.getLineStarts();
if (lineIndex >= lineStarts.length) return "";
const lineStart = lineStarts[lineIndex];
const lineEnd =
lineIndex + 1 < lineStarts.length
? lineStarts[lineIndex + 1]
: sourceText.length;
return sourceText.slice(lineStart, lineEnd);
}
function hasIgnoreComment(sourceText, sourceFile, node) {
const start = node.getStart(sourceFile);
const { line } = sourceFile.getLineAndCharacterOfPosition(start);
return [line - 1, line].some((lineIndex) =>
getLineText(sourceText, sourceFile, lineIndex).includes(IGNORE_COMMENT),
);
}
function getParentTagName(node) {
if (ts.isJsxElement(node.parent)) {
return node.parent.openingElement.tagName.getText();
}
if (ts.isJsxSelfClosingElement(node.parent)) {
return node.parent.tagName.getText();
}
return null;
}
function normalizeJsxText(text) {
return collapseWhitespace(text);
}
function extractStringFromExpression(expression) {
if (!expression) return null;
if (ts.isStringLiteralLike(expression)) {
return expression.text;
}
if (ts.isNoSubstitutionTemplateLiteral(expression)) {
return expression.text;
}
if (ts.isTemplateExpression(expression)) {
let text = expression.head.text;
for (const span of expression.templateSpans) {
text += `{${span.expression.getText()}}${span.literal.text}`;
}
return text;
}
return null;
}
function formatLocation(sourceFile, position) {
const { line, character } =
sourceFile.getLineAndCharacterOfPosition(position);
return `${sourceFile.fileName}:${line + 1}:${character + 1}`;
}
function collectViolations(filePath) {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const violations = [];
function report(node, kind, text) {
const normalizedText = collapseWhitespace(text);
if (!isProbablyUserFacingText(normalizedText)) return;
if (hasIgnoreComment(sourceText, sourceFile, node)) return;
violations.push({
location: formatLocation(sourceFile, node.getStart(sourceFile)),
kind,
text: normalizedText,
});
}
function visit(node) {
if (ts.isJsxText(node)) {
const tagName = getParentTagName(node);
if (tagName && TEXT_EXCLUDED_TAGS.has(tagName)) {
return;
}
const text = normalizeJsxText(node.getText(sourceFile));
report(node, "jsx-text", text);
}
if (ts.isJsxAttribute(node)) {
const attributeName = node.name.text;
if (TEXT_ATTRIBUTE_NAMES.has(attributeName) && node.initializer) {
if (ts.isStringLiteral(node.initializer)) {
report(
node.initializer,
`prop:${attributeName}`,
node.initializer.text,
);
}
if (ts.isJsxExpression(node.initializer)) {
const text = extractStringFromExpression(node.initializer.expression);
if (text) {
report(node.initializer, `prop:${attributeName}`, text);
}
}
}
}
if (ts.isJsxExpression(node) && node.expression) {
if (ts.isJsxAttribute(node.parent)) {
return;
}
const text = extractStringFromExpression(node.expression);
if (text) {
report(node, "jsx-expression", text);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return violations;
}
const files = CHECKED_PATHS.flatMap(walkPath)
.filter((filePath) => !isExcluded(filePath))
.sort();
const violations = files.flatMap((filePath) => collectViolations(filePath));
if (violations.length > 0) {
console.error("i18n string check failed:");
for (const violation of violations) {
console.error(
` - ${violation.location} [${violation.kind}] ${JSON.stringify(violation.text)}`,
);
}
console.error("");
console.error(
`Wrap user-facing strings in translations or annotate a narrow exception with "${IGNORE_COMMENT}".`,
);
console.error(
"The current enforcement scope is intentionally limited to app areas already migrated to i18n.",
);
process.exit(1);
} else {
console.log("i18n string check passed.");
}
-197
View File
@@ -1,197 +0,0 @@
#!/usr/bin/env swift
import AppKit
import Foundation
// Generate a dev icon with worktree name badge
// Usage: generate-dev-icon.swift <input-icns> <output-icns> <label>
guard CommandLine.arguments.count == 4 else {
fputs("Usage: \(CommandLine.arguments[0]) <input-icns> <output-icns> <label>\n", stderr)
exit(1)
}
let inputPath = CommandLine.arguments[1]
let outputPath = CommandLine.arguments[2]
let label = CommandLine.arguments[3]
// Load the icns file
guard let iconImage = NSImage(contentsOfFile: inputPath) else {
fputs("Failed to load image: \(inputPath)\n", stderr)
exit(1)
}
// Get the largest representation for best quality
guard let rep = iconImage.representations.max(by: { $0.pixelsWide < $1.pixelsWide }) else {
fputs("No image representations found\n", stderr)
exit(1)
}
let size = NSSize(width: rep.pixelsWide, height: rep.pixelsHigh)
// Create a new image with the badge
let newImage = NSImage(size: size)
newImage.lockFocus()
// Draw the original icon
iconImage.draw(in: NSRect(origin: .zero, size: size))
// Configure badge
let singleLineHeight = size.height * 0.22
let padding = size.width * 0.03
let maxBadgeWidth = size.width * 0.9
// Text attributes - calculate font size to fit
let maxFontSize = singleLineHeight * 0.65
var fontSize = maxFontSize
var attributes: [NSAttributedString.Key: Any]
// Helper to wrap text on `-` characters
func wrapText(_ text: String, maxWidth: CGFloat, attributes: [NSAttributedString.Key: Any]) -> [String] {
let singleLineSize = (text as NSString).size(withAttributes: attributes)
if singleLineSize.width <= maxWidth {
return [text]
}
// Split on `-` and try to form lines
let parts = text.components(separatedBy: "-")
if parts.count == 1 {
return [text] // No `-` to wrap on
}
var lines: [String] = []
var currentLine = ""
for (index, part) in parts.enumerated() {
let separator = index == 0 ? "" : "-"
let testLine = currentLine.isEmpty ? part : currentLine + separator + part
let testSize = (testLine as NSString).size(withAttributes: attributes)
if testSize.width <= maxWidth || currentLine.isEmpty {
currentLine = testLine
} else {
lines.append(currentLine)
currentLine = part
}
}
if !currentLine.isEmpty {
lines.append(currentLine)
}
return lines
}
// Find font size that fits (allowing up to 2 lines)
var lines: [String] = []
repeat {
attributes = [
.font: NSFont.systemFont(ofSize: fontSize, weight: .bold),
.foregroundColor: NSColor.white
]
lines = wrapText(label, maxWidth: maxBadgeWidth - padding * 4, attributes: attributes)
fontSize -= 1
} while lines.count > 2 && fontSize > 8
// Calculate text dimensions using typographic metrics
let lineHeight = (lines.first! as NSString).size(withAttributes: attributes).height
let textHeight = lineHeight * CGFloat(lines.count)
let maxLineWidth = lines.map { ($0 as NSString).size(withAttributes: attributes).width }.max() ?? 0
// Badge dimensions based on text
let badgeHeight = textHeight + padding * 2
let cornerRadius = badgeHeight * 0.2
let badgeWidth = maxLineWidth + padding * 4
let badgeX = (size.width - badgeWidth) / 2
let badgeY = size.height - badgeHeight - padding - size.height * 0.05
// Draw badge background (light blue semi-transparent)
let badgePath = NSBezierPath(roundedRect: NSRect(x: badgeX, y: badgeY, width: badgeWidth, height: badgeHeight),
xRadius: cornerRadius, yRadius: cornerRadius)
NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: 0.85).setFill()
badgePath.fill()
// Draw text centered in badge (multiple lines, bottom to top)
for (index, line) in lines.reversed().enumerated() {
let lineSize = (line as NSString).size(withAttributes: attributes)
let textX = badgeX + (badgeWidth - lineSize.width) / 2
let textY = badgeY + padding + lineHeight * CGFloat(index)
(line as NSString).draw(at: NSPoint(x: textX, y: textY), withAttributes: attributes)
}
newImage.unlockFocus()
// Convert to icns format
// First, create PNG data at multiple sizes for icns
guard let tiffData = newImage.tiffRepresentation,
let bitmapRep = NSBitmapImageRep(data: tiffData) else {
fputs("Failed to create bitmap representation\n", stderr)
exit(1)
}
// For simplicity, we'll create a PNG and then use iconutil if available,
// or just save as PNG for the icon (Tauri can use PNG)
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
fputs("Failed to create PNG data\n", stderr)
exit(1)
}
// If output is .icns, we need to create an iconset and use iconutil
if outputPath.hasSuffix(".icns") {
let tempDir = FileManager.default.temporaryDirectory
let iconsetPath = tempDir.appendingPathComponent("goose-dev.iconset")
// Remove existing iconset if present
try? FileManager.default.removeItem(at: iconsetPath)
try! FileManager.default.createDirectory(at: iconsetPath, withIntermediateDirectories: true)
// Generate all required sizes for iconset
let sizes: [(name: String, size: Int)] = [
("icon_16x16", 16),
("icon_16x16@2x", 32),
("icon_32x32", 32),
("icon_32x32@2x", 64),
("icon_128x128", 128),
("icon_128x128@2x", 256),
("icon_256x256", 256),
("icon_256x256@2x", 512),
("icon_512x512", 512),
("icon_512x512@2x", 1024)
]
for (name, targetSize) in sizes {
let resizedImage = NSImage(size: NSSize(width: targetSize, height: targetSize))
resizedImage.lockFocus()
NSGraphicsContext.current?.imageInterpolation = .high
newImage.draw(in: NSRect(x: 0, y: 0, width: targetSize, height: targetSize))
resizedImage.unlockFocus()
guard let resizedTiff = resizedImage.tiffRepresentation,
let resizedBitmap = NSBitmapImageRep(data: resizedTiff),
let resizedPng = resizedBitmap.representation(using: .png, properties: [:]) else {
continue
}
let filePath = iconsetPath.appendingPathComponent("\(name).png")
try! resizedPng.write(to: filePath)
}
// Use iconutil to create icns
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
process.arguments = ["-c", "icns", iconsetPath.path, "-o", outputPath]
try! process.run()
process.waitUntilExit()
// Cleanup
try? FileManager.default.removeItem(at: iconsetPath)
if process.terminationStatus != 0 {
fputs("iconutil failed\n", stderr)
exit(1)
}
} else {
// Just save as PNG
try! pngData.write(to: URL(fileURLWithPath: outputPath))
}
print("Generated: \(outputPath)")
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env bash
# Reset the provider inventory tables to empty, as if migration 12 just ran.
# This lets you test the first-use experience (cold inventory).
#
# Usage: ./scripts/reset-inventory.sh
set -euo pipefail
DB="${GOOSE_DB:-$HOME/.local/share/goose/sessions/sessions.db}"
if [ ! -f "$DB" ]; then
echo "Database not found at $DB"
echo "Set GOOSE_DB to override the path."
exit 1
fi
echo "Database: $DB"
echo ""
echo "Before:"
echo " provider_inventory_entries: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_entries;')"
echo " provider_inventory_models: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_models;')"
# ON DELETE CASCADE on provider_inventory_models means deleting entries clears both tables.
# Delete models first since CASCADE isn't reliable in all sqlite3 builds,
# then delete entries.
sqlite3 "$DB" "DELETE FROM provider_inventory_models; DELETE FROM provider_inventory_entries;"
echo ""
echo "After:"
echo " provider_inventory_entries: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_entries;')"
echo " provider_inventory_models: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_models;')"
echo ""
echo "Inventory tables are empty. Restart goose to test first-use flow."
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VITE_PORT="${1:-${VITE_PORT:-}}"
if [[ -z "${VITE_PORT}" ]]; then
echo "VITE_PORT is required" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PID=$(lsof -ti :"${VITE_PORT}" 2>/dev/null | head -1 || true)
if [[ -n "${PID}" ]]; then
PROC_ARGS="$(ps -p "${PID}" -o args= 2>/dev/null || true)"
PROC_CWD="$(lsof -a -p "${PID}" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)"
if [[ "${PROC_CWD}" == "${PROJECT_DIR}" && "${PROC_ARGS}" == *"--port ${VITE_PORT} --strictPort"* ]]; then
echo "Reusing existing Goose2 Vite dev server on port ${VITE_PORT} (PID ${PID})"
exit 0
fi
PROC_NAME="$(ps -p "${PID}" -o comm= 2>/dev/null || true)"
echo "Port ${VITE_PORT} is already in use by '${PROC_NAME}' (PID ${PID})." >&2
echo "Run 'just goose2 kill' if it is a stale Goose2 dev server, or rerun with VITE_PORT=<free-port> just goose2 dev." >&2
exit 1
fi
cd "${PROJECT_DIR}"
exec pnpm exec vite --port "${VITE_PORT}" --strictPort
-6598
View File
File diff suppressed because it is too large Load Diff
-51
View File
@@ -1,51 +0,0 @@
[package]
name = "goose2"
version = "0.1.0"
description = "Goose desktop app"
authors = ["you"]
edition = "2021"
[[bin]]
name = "goose-tauri"
path = "src/main.rs"
[lib]
name = "goose2_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-app-test-driver = { path = "plugins/app-test-driver" }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2"
tauri-plugin-log = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6.0.0"
log = "0.4.29"
tokio = { version = "1.50.0", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }
doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" }
ignore = "0.4.25"
base64 = "0.22"
mime_guess = "2"
tauri-plugin-shell = "2"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] }
[features]
app-test-driver = []
[dev-dependencies]
tempfile = "3"
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Goose uses your microphone to capture voice input for dictation.</string>
</dict>
</plist>
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
@@ -1,57 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-show",
"core:window:allow-close",
"core:window:allow-set-size",
"core:window:allow-set-min-size",
"opener:default",
{
"identifier": "opener:allow-open-path",
"allow": [
{
"path": "$HOME/**"
},
{
"path": "$HOME/.goose/**"
},
{
"path": "$TEMP/**"
},
{
"path": "/Volumes/**"
},
{
"path": "/mnt/**"
},
{
"path": "/workspace/**"
},
{
"path": "/workspaces/**"
},
{
"path": "/opt/**"
},
{
"path": "/srv/**"
},
{
"path": "*:/**"
}
]
},
"window-state:allow-restore-state",
"window-state:allow-save-window-state",
"dialog:allow-open",
"dialog:allow-save",
"app-test-driver:default",
"core:webview:allow-set-webview-zoom"
]
}
-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
</dict>
</plist>
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","core:window:allow-set-size","core:window:allow-set-min-size","opener:default",{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","app-test-driver:default","core:webview:allow-set-webview-zoom"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Some files were not shown because too many files have changed in this diff Show More