Merge pull request #1401 from AndyMik90/develop

Version 2.7.5 Release (Proper Merge)
This commit is contained in:
Andy
2026-01-21 12:41:22 +01:00
committed by GitHub
440 changed files with 37584 additions and 24928 deletions
+14 -1
View File
@@ -39,10 +39,23 @@ Follow conventional commits: `<type>: <subject>`
- [ ] I've tested my changes locally
- [ ] I've followed the code principles (SOLID, DRY, KISS)
- [ ] My PR is small and focused (< 400 lines ideally)
- [ ] **(Python only)** All file operations specify `encoding="utf-8"` for text files
## Platform Testing Checklist
**CRITICAL:** This project supports Windows, macOS, and Linux. Platform-specific bugs are a common source of breakage.
- [ ] **Windows tested** (either on Windows or via CI)
- [ ] **macOS tested** (either on macOS or via CI)
- [ ] **Linux tested** (CI covers this)
- [ ] Used centralized `platform/` module instead of direct `process.platform` checks
- [ ] No hardcoded paths (used `findExecutable()` or platform abstractions)
**If you only have access to one OS:** CI now tests on all platforms. Ensure all checks pass before submitting.
## CI/Testing Requirements
- [ ] All CI checks pass
- [ ] All CI checks pass on **all platforms** (Windows, macOS, Linux)
- [ ] All existing tests pass
- [ ] New features include test coverage
- [ ] Bug fixes include regression tests
@@ -0,0 +1,160 @@
name: 'Finalize macOS Notarization'
description: 'Wait for Apple notarization to complete and staple tickets to DMG files'
inputs:
apple-id:
description: 'Apple ID for notarization'
required: true
apple-app-specific-password:
description: 'Apple app-specific password'
required: true
apple-team-id:
description: 'Apple Team ID'
required: true
intel-notarization-id:
description: 'Notarization request ID for Intel build'
required: false
default: ''
arm64-notarization-id:
description: 'Notarization request ID for ARM64 build'
required: false
default: ''
intel-dmg-file:
description: 'Filename of the Intel DMG'
required: false
default: ''
arm64-dmg-file:
description: 'Filename of the ARM64 DMG'
required: false
default: ''
intel-artifact-path:
description: 'Path to Intel build artifacts'
required: false
default: 'intel'
arm64-artifact-path:
description: 'Path to ARM64 build artifacts'
required: false
default: 'arm64'
timeout:
description: 'Timeout in seconds for notarization wait'
required: false
default: '3600'
outputs:
intel-stapled:
description: 'Whether Intel DMG was successfully stapled'
value: ${{ steps.staple.outputs.intel_stapled }}
arm64-stapled:
description: 'Whether ARM64 DMG was successfully stapled'
value: ${{ steps.staple.outputs.arm64_stapled }}
runs:
using: 'composite'
steps:
- name: Wait for notarization and staple
id: staple
shell: bash
env:
APPLE_ID: ${{ inputs.apple-id }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
INTEL_NOTARIZATION_ID: ${{ inputs.intel-notarization-id }}
ARM64_NOTARIZATION_ID: ${{ inputs.arm64-notarization-id }}
INTEL_DMG: ${{ inputs.intel-dmg-file }}
ARM64_DMG: ${{ inputs.arm64-dmg-file }}
INTEL_PATH: ${{ inputs.intel-artifact-path }}
ARM64_PATH: ${{ inputs.arm64-artifact-path }}
TIMEOUT: ${{ inputs.timeout }}
run: |
intel_stapled=false
arm64_stapled=false
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization wait: APPLE_ID not configured"
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Warn if no notarization IDs provided (could indicate submission failure)
if [ -z "$INTEL_NOTARIZATION_ID" ] && [ -z "$ARM64_NOTARIZATION_ID" ]; then
echo "::warning::No notarization IDs provided - nothing to finalize. Check if notarization submission succeeded."
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Wait for Intel notarization
if [ -n "$INTEL_NOTARIZATION_ID" ]; then
echo "Waiting for Intel notarization: $INTEL_NOTARIZATION_ID"
if ! xcrun notarytool wait "$INTEL_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--timeout "$TIMEOUT"; then
echo "::error::Intel notarization failed or timed out"
exit 1
fi
# Verify notarization was accepted (not just processed)
INTEL_STATUS=$(xcrun notarytool info "$INTEL_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--output-format json | jq -r '.status // "Unknown"')
if [ "$INTEL_STATUS" != "Accepted" ]; then
echo "::error::Intel notarization status is '$INTEL_STATUS', expected 'Accepted'"
exit 1
fi
echo "Intel notarization status: $INTEL_STATUS"
# Verify DMG file exists before stapling
if [ ! -f "$INTEL_PATH/$INTEL_DMG" ]; then
echo "::error::Intel DMG not found at $INTEL_PATH/$INTEL_DMG"
exit 1
fi
echo "Stapling Intel DMG: $INTEL_PATH/$INTEL_DMG"
if ! xcrun stapler staple "$INTEL_PATH/$INTEL_DMG"; then
echo "::error::Failed to staple Intel DMG"
exit 1
fi
echo "Successfully stapled Intel DMG"
intel_stapled=true
fi
# Wait for ARM64 notarization
if [ -n "$ARM64_NOTARIZATION_ID" ]; then
echo "Waiting for ARM64 notarization: $ARM64_NOTARIZATION_ID"
if ! xcrun notarytool wait "$ARM64_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--timeout "$TIMEOUT"; then
echo "::error::ARM64 notarization failed or timed out"
exit 1
fi
# Verify notarization was accepted (not just processed)
ARM64_STATUS=$(xcrun notarytool info "$ARM64_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--output-format json | jq -r '.status // "Unknown"')
if [ "$ARM64_STATUS" != "Accepted" ]; then
echo "::error::ARM64 notarization status is '$ARM64_STATUS', expected 'Accepted'"
exit 1
fi
echo "ARM64 notarization status: $ARM64_STATUS"
# Verify DMG file exists before stapling
if [ ! -f "$ARM64_PATH/$ARM64_DMG" ]; then
echo "::error::ARM64 DMG not found at $ARM64_PATH/$ARM64_DMG"
exit 1
fi
echo "Stapling ARM64 DMG: $ARM64_PATH/$ARM64_DMG"
if ! xcrun stapler staple "$ARM64_PATH/$ARM64_DMG"; then
echo "::error::Failed to staple ARM64 DMG"
exit 1
fi
echo "Successfully stapled ARM64 DMG"
arm64_stapled=true
fi
echo "intel_stapled=$intel_stapled" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=$arm64_stapled" >> "$GITHUB_OUTPUT"
@@ -0,0 +1,193 @@
name: 'Merge macOS Manifests'
description: 'Merge Intel and ARM64 macOS manifests for electron-updater'
inputs:
dist-path:
description: 'Path to the dist directory containing build artifacts'
required: false
default: 'dist'
output-path:
description: 'Path to output the merged manifest'
required: false
default: 'release-assets'
copy-other-manifests:
description: 'Whether to copy Windows/Linux manifests as well'
required: false
default: 'true'
yq-version:
description: 'Version of yq to use for YAML merging'
required: false
default: 'v4.44.3'
outputs:
merged:
description: 'Whether manifests were merged (true) or single architecture used (false)'
value: ${{ steps.merge.outputs.merged }}
file-count:
description: 'Number of files in the merged manifest'
value: ${{ steps.validate.outputs.file_count }}
runs:
using: 'composite'
steps:
- name: Merge macOS manifests
id: merge
shell: bash
env:
# yq SHA256 checksum for v4.44.3 linux_amd64
# When updating yq-version, update this checksum and the one in validate step
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
run: |
echo "=== Merging macOS update manifests ==="
# Find all latest-mac.yml files from different build artifacts
intel_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-intel-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
arm64_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-arm64-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
echo "Intel manifest: ${intel_manifest:-not found}"
echo "ARM64 manifest: ${arm64_manifest:-not found}"
mkdir -p "${{ inputs.output-path }}"
if [ -n "$intel_manifest" ] && [ -n "$arm64_manifest" ]; then
echo "Both architectures found - merging manifests..."
echo "merged=true" >> "$GITHUB_OUTPUT"
# Install yq for YAML merging (pinned version with checksum verification)
YQ_VERSION="${{ inputs.yq-version }}"
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "Downloading yq ${YQ_VERSION}..."
if ! wget -qO /tmp/yq "$YQ_URL"; then
echo "::error::Failed to download yq ${YQ_VERSION}"
exit 1
fi
# Verify checksum
echo "Verifying yq checksum..."
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
echo "::error::yq checksum verification failed!"
echo "Expected: $YQ_SHA256"
echo "Actual: $ACTUAL_SHA256"
rm -f /tmp/yq
exit 1
fi
echo "Checksum verified successfully"
sudo mv /tmp/yq /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
echo "Installed yq version:"
yq --version
# Merge the files arrays from both manifests using eval-all
# This avoids shell expansion issues with multiline YAML
yq eval-all '
select(fileIndex == 0) * {"files": ([.[].files] | add)}
' "$intel_manifest" "$arm64_manifest" > "${{ inputs.output-path }}/latest-mac.yml"
echo "Merged manifest contents:"
cat "${{ inputs.output-path }}/latest-mac.yml"
elif [ -n "$intel_manifest" ]; then
echo "Only Intel manifest found - using as-is"
echo "merged=false" >> "$GITHUB_OUTPUT"
cp "$intel_manifest" "${{ inputs.output-path }}/latest-mac.yml"
elif [ -n "$arm64_manifest" ]; then
echo "Only ARM64 manifest found - using as-is"
echo "merged=false" >> "$GITHUB_OUTPUT"
cp "$arm64_manifest" "${{ inputs.output-path }}/latest-mac.yml"
else
echo "::error::No macOS manifests found - this will cause auto-update to fail"
exit 1
fi
- name: Validate merged manifest
id: validate
shell: bash
env:
# Single source of truth for yq checksum - must match merge step
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
run: |
manifest_file="${{ inputs.output-path }}/latest-mac.yml"
echo "=== Validating merged manifest ==="
# Check file exists
if [ ! -f "$manifest_file" ]; then
echo "::error::Merged manifest file not found at $manifest_file"
exit 1
fi
# Install yq if not already installed (for single-arch case)
if ! command -v yq &> /dev/null; then
YQ_VERSION="${{ inputs.yq-version }}"
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "Downloading yq ${YQ_VERSION}..."
wget -qO /tmp/yq "$YQ_URL"
# Verify checksum (YQ_SHA256 from env)
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
echo "::error::yq checksum verification failed!"
echo "Expected: $YQ_SHA256"
echo "Actual: $ACTUAL_SHA256"
exit 1
fi
sudo mv /tmp/yq /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
fi
# Validate YAML is parseable
if ! yq eval '.' "$manifest_file" > /dev/null 2>&1; then
echo "::error::Merged manifest is not valid YAML"
cat "$manifest_file"
exit 1
fi
echo "YAML syntax is valid"
# Count files in manifest
file_count=$(yq eval '.files | length' "$manifest_file")
echo "file_count=$file_count" >> "$GITHUB_OUTPUT"
echo "Manifest contains $file_count file entries"
# Validate file count
if [ "$file_count" -eq 0 ]; then
echo "::error::Merged manifest contains no files"
exit 1
fi
# If we merged both architectures, expect at least 2 files (one per arch)
if [ "${{ steps.merge.outputs.merged }}" = "true" ] && [ "$file_count" -lt 2 ]; then
echo "::warning::Merged manifest has fewer than 2 files - merge may have failed"
fi
# Validate required fields exist
if ! yq eval '.version' "$manifest_file" | grep -q .; then
echo "::error::Manifest missing 'version' field"
exit 1
fi
echo "Version field present: $(yq eval '.version' "$manifest_file")"
echo "Manifest validation passed"
- name: Copy other manifests
if: inputs.copy-other-manifests == 'true'
shell: bash
run: |
echo "=== Copying other update manifests ==="
# Copy other manifests (Windows, Linux) - these don't have the duplicate issue
for manifest in latest.yml latest-linux.yml latest-linux-arm64.yml; do
found=$(find "${{ inputs.dist-path }}" -name "$manifest" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then
echo "Copying $manifest"
cp "$found" "${{ inputs.output-path }}/"
fi
done
echo ""
echo "=== Manifest files in ${{ inputs.output-path }} ==="
ls -la "${{ inputs.output-path }}"/*.yml 2>/dev/null || echo "No manifest files found"
@@ -0,0 +1,87 @@
name: 'Setup Node.js Frontend'
description: 'Set up Node.js with npm and cached dependencies for the frontend'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '24'
ignore-scripts:
description: 'Whether to use --ignore-scripts flag during npm ci'
required: false
default: 'false'
outputs:
cache-hit:
description: 'Whether npm cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Setup Node.js ${{ inputs.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Get npm cache directory
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Cache npm dependencies
id: cache
uses: actions/cache@v4
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
shell: bash
# Run npm ci from root to properly handle workspace dependencies.
# With npm workspaces, the lock file is at root and dependencies are hoisted there.
# Running npm ci in apps/frontend would fail to populate node_modules correctly.
run: |
if [ "${{ inputs.ignore-scripts }}" == "true" ]; then
npm ci --ignore-scripts
else
npm ci
fi
- name: Link node_modules for electron-builder
shell: bash
# electron-builder expects node_modules in apps/frontend for native module rebuilding.
# With npm workspaces, packages are hoisted to root. Create a link so electron-builder
# can find the modules during packaging and code signing.
# Uses symlink on Unix, directory junction on Windows (works without admin privileges).
run: |
# Verify npm ci succeeded
if [ ! -d "node_modules" ]; then
echo "::error::Root node_modules does not exist. npm ci may have failed."
exit 1
fi
# Create link if needed
if [ ! -d "apps/frontend/node_modules" ] && [ ! -L "apps/frontend/node_modules" ]; then
if [ "$RUNNER_OS" == "Windows" ]; then
# Use directory junction on Windows (works without admin privileges)
cmd //c "mklink /J apps\\frontend\\node_modules ..\\..\\node_modules"
if [ $? -eq 0 ]; then
echo "Created junction: apps/frontend/node_modules -> ../../node_modules"
else
echo "::error::Failed to create directory junction on Windows"
exit 1
fi
else
# Use symlink on Unix (macOS/Linux)
if ln -s ../../node_modules apps/frontend/node_modules; then
echo "Created symlink: apps/frontend/node_modules -> ../../node_modules"
else
echo "::error::Failed to create symlink"
exit 1
fi
fi
else
echo "apps/frontend/node_modules already exists, skipping"
fi
@@ -0,0 +1,52 @@
name: 'Setup Python Backend'
description: 'Set up Python with uv package manager and cached dependencies for the backend'
inputs:
python-version:
description: 'Python version to use'
required: false
default: '3.12'
install-test-deps:
description: 'Whether to install test dependencies'
required: false
default: 'false'
outputs:
cache-hit:
description: 'Whether cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Install uv package manager
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Cache uv dependencies
id: cache
uses: actions/cache@v4
with:
path: |
~/.cache/uv
~/AppData/Local/uv/cache
~/Library/Caches/uv
key: uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-${{ hashFiles('apps/backend/requirements.txt', 'tests/requirements-test.txt') }}
restore-keys: |
uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-
- name: Install dependencies
working-directory: apps/backend
shell: bash
run: |
uv venv
uv pip install -r requirements.txt
if [ "${{ inputs.install-test-deps }}" == "true" ]; then
uv pip install -r ../../tests/requirements-test.txt
fi
@@ -0,0 +1,87 @@
name: 'Submit macOS Notarization'
description: 'Submit a macOS DMG file for Apple notarization asynchronously'
inputs:
apple-id:
description: 'Apple ID for notarization'
required: true
apple-app-specific-password:
description: 'Apple app-specific password'
required: true
apple-team-id:
description: 'Apple Team ID'
required: true
dmg-path:
description: 'Path to the dist directory containing the DMG file'
required: false
default: 'apps/frontend/dist'
outputs:
notarization-id:
description: 'The notarization request ID'
value: ${{ steps.submit.outputs.notarization_id }}
dmg-file:
description: 'The DMG filename that was submitted'
value: ${{ steps.submit.outputs.dmg_file }}
runs:
using: 'composite'
steps:
- name: Submit notarization (async)
id: submit
shell: bash
env:
APPLE_ID: ${{ inputs.apple-id }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
DMG_PATH: ${{ inputs.dmg-path }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
echo "notarization_id=" >> "$GITHUB_OUTPUT"
echo "dmg_file=" >> "$GITHUB_OUTPUT"
exit 0
fi
# Find the DMG file
DMG_FILE=$(find "$DMG_PATH" -name "*.dmg" -type f | head -1)
if [ -z "$DMG_FILE" ]; then
echo "::error::No DMG file found in $DMG_PATH"
exit 1
fi
echo "Submitting $DMG_FILE for notarization (async)..."
# Submit for notarization without waiting
# Capture both stdout and exit code
set +e
RESULT=$(xcrun notarytool submit "$DMG_FILE" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--no-wait \
--output-format json 2>&1)
SUBMIT_EXIT_CODE=$?
set -e
echo "$RESULT"
# Check if submission command itself failed (not just missing ID)
if [ $SUBMIT_EXIT_CODE -ne 0 ]; then
echo "::error::notarytool submit failed with exit code $SUBMIT_EXIT_CODE"
exit 1
fi
# Extract the notarization ID from JSON response
# jq is always available on macOS runners
NOTARIZATION_ID=$(echo "$RESULT" | jq -r '.id // empty' 2>/dev/null)
if [ -z "$NOTARIZATION_ID" ]; then
echo "::error::Failed to get notarization ID from response"
echo "Response was: $RESULT"
exit 1
fi
echo "Notarization submitted with ID: $NOTARIZATION_ID"
echo "notarization_id=$NOTARIZATION_ID" >> "$GITHUB_OUTPUT"
echo "dmg_file=$(basename "$DMG_FILE")" >> "$GITHUB_OUTPUT"
+230 -117
View File
@@ -65,6 +65,9 @@ jobs:
build-macos-intel:
needs: create-tag
runs-on: macos-15-intel
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
with:
@@ -76,23 +79,8 @@ jobs:
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
@@ -132,27 +120,13 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Notarize macOS Intel app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -167,6 +141,9 @@ jobs:
build-macos-arm64:
needs: create-tag
runs-on: macos-15
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
with:
@@ -178,23 +155,8 @@ jobs:
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Cache pip wheel cache
uses: actions/cache@v4
@@ -231,27 +193,13 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Notarize macOS ARM64 app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -282,24 +230,8 @@ jobs:
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
shell: bash
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Cache pip wheel cache
uses: actions/cache@v4
@@ -470,23 +402,8 @@ jobs:
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Flatpak
run: |
@@ -540,8 +457,50 @@ jobs:
apps/frontend/dist/*.flatpak
apps/frontend/dist/*.yml
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
finalize-notarization:
needs: [build-macos-intel, build-macos-arm64]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Download Intel DMG
uses: actions/download-artifact@v4
with:
name: macos-intel-builds
path: intel
- name: Download ARM64 DMG
uses: actions/download-artifact@v4
with:
name: macos-arm64-builds
path: arm64
- name: Wait for notarization and staple
uses: ./.github/actions/finalize-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
- name: Upload stapled Intel DMG
uses: actions/upload-artifact@v4
with:
name: macos-intel-stapled
path: intel/*.dmg
- name: Upload stapled ARM64 DMG
uses: actions/upload-artifact@v4
with:
name: macos-arm64-stapled
path: arm64/*.dmg
create-release:
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
needs: [create-tag, finalize-notarization, build-windows, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.dry_run != 'true' }}
permissions:
@@ -557,10 +516,24 @@ jobs:
with:
path: dist
- name: Flatten and validate artifacts
- name: Flatten binary artifacts
run: |
mkdir -p release-assets
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" \) -exec cp {} release-assets/ \;
# Copy stapled macOS DMGs (from finalize-notarization job)
# Validate that stapled DMGs exist before copying
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
else
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
fi
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts (including blockmap for delta updates)
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Validate that at least one artifact was copied
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
@@ -569,9 +542,84 @@ jobs:
exit 1
fi
echo "Found $artifact_count artifact(s):"
echo "Found $artifact_count binary artifact(s):"
ls -la release-assets/
# Merge macOS manifests from Intel and ARM64 builds
# See: https://github.com/electron-userland/electron-builder/issues/5592
- name: Merge macOS manifests
uses: ./.github/actions/merge-macos-manifests
with:
dist-path: dist
output-path: release-assets
copy-other-manifests: 'true'
- name: Rename and validate beta manifests
run: |
cd release-assets
echo "=== Current manifest files ==="
ls -la *.yml 2>/dev/null || echo "No yml files found yet"
# electron-builder generates latest*.yml files by default
# For beta channel, electron-updater expects beta*.yml files
# Rename: latest.yml -> beta.yml, latest-mac.yml -> beta-mac.yml, latest-linux.yml -> beta-linux.yml
# Windows: latest.yml -> beta.yml
if [ -f "latest.yml" ]; then
echo "Renaming latest.yml -> beta.yml (Windows)"
mv latest.yml beta.yml
fi
# macOS: latest-mac.yml -> beta-mac.yml
if [ -f "latest-mac.yml" ]; then
echo "Renaming latest-mac.yml -> beta-mac.yml (macOS)"
mv latest-mac.yml beta-mac.yml
fi
# Linux: latest-linux.yml -> beta-linux.yml
if [ -f "latest-linux.yml" ]; then
echo "Renaming latest-linux.yml -> beta-linux.yml (Linux)"
mv latest-linux.yml beta-linux.yml
fi
# Linux ARM64: latest-linux-arm64.yml -> beta-linux-arm64.yml (if exists)
if [ -f "latest-linux-arm64.yml" ]; then
echo "Renaming latest-linux-arm64.yml -> beta-linux-arm64.yml (Linux ARM64)"
mv latest-linux-arm64.yml beta-linux-arm64.yml
fi
echo ""
echo "=== Beta manifest files after rename ==="
ls -la *.yml 2>/dev/null || echo "No yml files found"
# Validate required beta manifests exist
missing_manifests=""
if [ ! -f "beta-mac.yml" ]; then
missing_manifests="$missing_manifests beta-mac.yml"
fi
if [ ! -f "beta.yml" ]; then
missing_manifests="$missing_manifests beta.yml"
fi
if [ ! -f "beta-linux.yml" ]; then
missing_manifests="$missing_manifests beta-linux.yml"
fi
if [ -n "$missing_manifests" ]; then
echo "::error::Missing required beta manifests:$missing_manifests"
echo "::error::Auto-update will fail on affected platforms without these files!"
exit 1
fi
echo ""
echo "All required beta manifests present:"
echo " - beta-mac.yml (macOS)"
echo " - beta.yml (Windows)"
echo " - beta-linux.yml (Linux)"
- name: Generate checksums
run: |
cd release-assets
@@ -606,24 +654,89 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
dry-run-summary:
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
needs: [create-tag, finalize-notarization, build-windows, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.dry_run == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist
- name: Flatten binary artifacts
run: |
mkdir -p release-assets
# Copy stapled macOS DMGs (from finalize-notarization job)
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts (including blockmap for delta updates)
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Merge macOS manifests (same logic as real release)
- name: Merge macOS manifests
uses: ./.github/actions/merge-macos-manifests
with:
dist-path: dist
output-path: release-assets
copy-other-manifests: 'true'
- name: Validate and rename beta manifests
run: |
cd release-assets
# Rename latest*.yml to beta*.yml
[ -f "latest.yml" ] && mv latest.yml beta.yml
[ -f "latest-mac.yml" ] && mv latest-mac.yml beta-mac.yml
[ -f "latest-linux.yml" ] && mv latest-linux.yml beta-linux.yml
[ -f "latest-linux-arm64.yml" ] && mv latest-linux-arm64.yml beta-linux-arm64.yml
# Validate required manifests
missing=""
[ ! -f "beta-mac.yml" ] && missing="$missing beta-mac.yml"
[ ! -f "beta.yml" ] && missing="$missing beta.yml"
[ ! -f "beta-linux.yml" ] && missing="$missing beta-linux.yml"
if [ -n "$missing" ]; then
echo "::warning::DRY RUN: Missing required beta manifests:$missing"
echo "MANIFEST_STATUS=FAILED" >> $GITHUB_ENV
else
echo "MANIFEST_STATUS=PASSED" >> $GITHUB_ENV
# Show merged manifest content for verification
echo ""
echo "=== beta-mac.yml content (should have both architectures) ==="
cat beta-mac.yml
fi
- name: Dry run summary
run: |
echo "## Beta Release Dry Run Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ needs.create-tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY
echo "### Build Artifacts" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Update Manifests (Required for Auto-Update)" >> $GITHUB_STEP_SUMMARY
if [ "$MANIFEST_STATUS" = "PASSED" ]; then
echo "All required beta manifests present:" >> $GITHUB_STEP_SUMMARY
echo "- beta-mac.yml (macOS)" >> $GITHUB_STEP_SUMMARY
echo "- beta.yml (Windows)" >> $GITHUB_STEP_SUMMARY
echo "- beta-linux.yml (Linux)" >> $GITHUB_STEP_SUMMARY
else
echo "**WARNING: Missing required manifests! Auto-update will fail.**" >> $GITHUB_STEP_SUMMARY
echo "Check build logs for details." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY
+105 -52
View File
@@ -1,10 +1,38 @@
# Cross-Platform CI Pipeline
#
# Tests on all target platforms (Linux, Windows, macOS) to catch
# platform-specific bugs before they merge. ALL platforms must pass.
#
# Optimized: Reduced matrix (4 jobs vs 6), merged integration tests,
# coverage on Linux only, path filters to skip on docs-only changes.
name: CI
on:
push:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- 'package*.json'
- 'requirements*.txt'
- 'pyproject.toml'
- 'tsconfig*.json'
- 'biome.jsonc'
- '.github/workflows/ci.yml'
- '.github/actions/**'
pull_request:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- 'package*.json'
- 'requirements*.txt'
- 'pyproject.toml'
- 'tsconfig*.json'
- 'biome.jsonc'
- '.github/workflows/ci.yml'
- '.github/actions/**'
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
@@ -15,53 +43,63 @@ permissions:
actions: read
jobs:
# Python tests
# --------------------------------------------------------------------------
# Python Backend Tests - Optimized Matrix (4 jobs instead of 6)
# --------------------------------------------------------------------------
test-python:
runs-on: ubuntu-latest
name: test-python (${{ matrix.python-version }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13']
# 3.12 on all OS for cross-platform coverage
# 3.13 on Linux only for compatibility check (saves 2 jobs)
include:
- os: ubuntu-latest
python-version: '3.12'
- os: ubuntu-latest
python-version: '3.13'
- os: windows-latest
python-version: '3.12'
- os: macos-latest
python-version: '3.12'
steps:
- name: Checkout
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
- name: Setup Python backend
uses: ./.github/actions/setup-python-backend
with:
python-version: ${{ matrix.python-version }}
install-test-deps: 'true'
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
- name: Run all tests (including platform-specific)
working-directory: apps/backend
shell: bash
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
if [ "$RUNNER_OS" == "Windows" ]; then
source .venv/Scripts/activate
else
source .venv/bin/activate
fi
pytest ../../tests/ -v --tb=short -x
- name: Run tests with coverage
if: matrix.python-version == '3.12'
- name: Run coverage (Linux + Python 3.12 only)
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
working-directory: apps/backend
shell: bash
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=10
- name: Upload coverage reports
if: matrix.python-version == '3.12'
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
file: ./apps/backend/coverage.xml
@@ -69,44 +107,59 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# Frontend lint, typecheck, test, and build
# --------------------------------------------------------------------------
# Frontend Tests - All Platforms
# --------------------------------------------------------------------------
test-frontend:
runs-on: ubuntu-latest
name: test-frontend (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Setup Node.js frontend
uses: ./.github/actions/setup-node-frontend
with:
node-version: '24'
ignore-scripts: 'true'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Lint
working-directory: apps/frontend
run: npm run lint
- name: Type check
- name: Run TypeScript type check
working-directory: apps/frontend
run: npm run typecheck
- name: Run tests
- name: Run unit tests
working-directory: apps/frontend
run: npm run test
- name: Build
- name: Build application
working-directory: apps/frontend
run: npm run build
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
ci-complete:
name: CI Complete
runs-on: ubuntu-latest
needs: [test-python, test-frontend]
if: always()
steps:
- name: Check all CI jobs passed
run: |
echo "CI Job Results:"
echo " test-python: ${{ needs.test-python.result }}"
echo " test-frontend: ${{ needs.test-frontend.result }}"
echo ""
if [[ "${{ needs.test-python.result }}" != "success" ]] || \
[[ "${{ needs.test-frontend.result }}" != "success" ]]; then
echo "❌ One or more CI jobs failed"
exit 1
fi
echo "✅ All CI checks passed"
+57 -2
View File
@@ -3,16 +3,31 @@ name: Lint
on:
push:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- '.github/workflows/lint.yml'
- '.github/actions/**'
- 'apps/frontend/biome.jsonc'
- '.pre-commit-config.yaml'
pull_request:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- '.github/workflows/lint.yml'
- '.github/actions/**'
- 'apps/frontend/biome.jsonc'
- '.pre-commit-config.yaml'
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Python linting
# Python linting (Ruff) - already fast, no changes needed
python:
name: Python (Ruff)
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -23,7 +38,7 @@ jobs:
with:
python-version: '3.12'
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
# Pin ruff version to match .pre-commit-config.yaml
- name: Install ruff
run: pip install ruff==0.14.10
@@ -32,3 +47,43 @@ jobs:
- name: Run ruff format check
run: ruff format apps/backend/ --check --diff
# TypeScript/JavaScript linting (Biome) - 15-25x faster than ESLint
typescript:
name: TypeScript (Biome)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Pin version to match package.json for consistent behavior
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
version: 2.3.11
- name: Run Biome
working-directory: apps/frontend
# biome ci fails on errors by default; warnings are reported but don't block
# Use --error-on-warnings when ready to enforce all rules
run: biome ci .
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
lint-complete:
name: Lint Complete
runs-on: ubuntu-latest
needs: [python, typescript]
if: always()
steps:
- name: Check lint results
run: |
if [[ "${{ needs.python.result }}" != "success" ]] || \
[[ "${{ needs.typescript.result }}" != "success" ]]; then
echo "❌ Linting failed"
echo " Python: ${{ needs.python.result }}"
echo " TypeScript: ${{ needs.typescript.result }}"
exit 1
fi
echo "✅ All linting passed"
+4 -25
View File
@@ -64,9 +64,7 @@ jobs:
// Label definitions
LABELS: Object.freeze({
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'],
STATUS: ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'],
REVIEW: ['Missing AC Approval', 'AC: Approved', 'AC: Changes Requested', 'AC: Needs Re-review']
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci']
}),
// Pagination
@@ -230,7 +228,6 @@ jobs:
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title || '';
const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened';
console.log(`::group::PR #${prNumber} - Auto-labeling`);
console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`);
@@ -271,25 +268,9 @@ jobs:
CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`);
// 4. Set status label (only on new PRs - let pr-status-gate handle updates on pushes)
// Note: On synchronize events, CI workflows will trigger pr-status-gate when they complete
if (isNewPR) {
labelsToAdd.add('🔄 Checking');
CONFIG.LABELS.STATUS.filter(l => l !== '🔄 Checking').forEach(l => labelsToRemove.add(l));
console.log(` 🔄 Status: Checking`);
} else {
console.log(` ℹ️ Status: Unchanged (will be updated by pr-status-gate)`);
}
// 5. Add review label for new PRs only
if (isNewPR) {
labelsToAdd.add('Missing AC Approval');
console.log(` ⏳ Review: Missing AC Approval`);
}
console.log('::endgroup::');
// 6. Apply label changes
// 4. Apply label changes
console.log(`::group::Applying labels`);
// Remove labels that should be replaced (exclude ones we're adding)
@@ -302,7 +283,7 @@ jobs:
console.log('::endgroup::');
console.log(`✅ PR #${prNumber} labeled successfully`);
// 7. Write job summary
// 5. Write job summary
const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none';
const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other';
@@ -312,9 +293,7 @@ jobs:
[{ data: 'Category', header: true }, { data: 'Label', header: true }],
['Type', summaryType],
['Area', summaryArea],
['Size', sizeLabel],
['Status', isNewPR ? '🔄 Checking' : '(unchanged)'],
['Review', isNewPR ? 'Missing AC Approval' : '(unchanged)']
['Size', sizeLabel]
])
.addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`)
.write();
-585
View File
@@ -1,585 +0,0 @@
name: PR Status Gate
on:
workflow_run:
workflows: [CI, Lint, Quality Security]
types: [completed]
issue_comment:
types: [created, edited]
pull_request:
types: [synchronize]
concurrency:
group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
permissions:
pull-requests: write
checks: read
env:
# Shared configuration - single source of truth
REQUIRED_CHECKS: |
CI / test-frontend
CI / test-python (3.12)
CI / test-python (3.13)
Lint / python
Quality Security / CodeQL (javascript-typescript)
Quality Security / CodeQL (python)
Quality Security / Python Security (Bandit)
Quality Security / Security Summary
jobs:
# ═══════════════════════════════════════════════════════════════════════════
# JOB 1: CI STATUS (triggered by workflow_run)
# Updates CI status labels when monitored workflows complete
# ═══════════════════════════════════════════════════════════════════════════
update-ci-status:
name: Update CI Status
runs-on: ubuntu-latest
if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null
timeout-minutes: 5
steps:
- name: Check all required checks and update label
uses: actions/github-script@v7
env:
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
// If label values change, update ALL occurrences: update-ci-status, check-status-command
const STATUS_LABELS = Object.freeze({
CHECKING: '🔄 Checking',
PASSED: '✅ Ready for Review',
FAILED: '❌ Checks Failed'
});
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
.split('\n')
.map(s => s.trim())
.filter(Boolean);
async function fetchCheckRuns(sha) {
const { owner, repo } = context.repo;
// Let the configured retries (retries: 3) handle transient failures
// Don't catch errors - allow them to propagate for retry logic
const checkRuns = await github.paginate(
github.rest.checks.listForRef,
{ owner, repo, ref: sha, per_page: 100 },
(response) => response.data
);
return checkRuns;
}
function analyzeChecks(checkRuns) {
const results = [];
let allComplete = true;
let anyFailed = false;
for (const checkName of REQUIRED_CHECKS) {
const check = checkRuns.find(c => c.name === checkName);
if (!check) {
results.push({ name: checkName, status: '⏳ Pending', complete: false });
allComplete = false;
} else if (check.status !== 'completed') {
results.push({ name: checkName, status: '🔄 Running', complete: false });
allComplete = false;
} else if (check.conclusion === 'success') {
results.push({ name: checkName, status: '✅ Passed', complete: true });
} else if (check.conclusion === 'skipped') {
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
} else {
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
anyFailed = true;
}
}
return { allComplete, anyFailed, results };
}
async function updateStatusLabels(prNumber, newLabel) {
const { owner, repo } = context.repo;
const allLabels = Object.values(STATUS_LABELS);
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
for (const label of allLabels) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
} catch (e) {
if (e && e.status !== 404) {
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
throw new Error(`Failed to remove label '${label}': ${e.message}`);
}
}
}
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
} catch (e) {
if (e && e.status === 404) {
core.warning(`Label '${newLabel}' does not exist`);
} else {
throw e;
}
}
}
// Main logic
const prNumber = context.payload.workflow_run.pull_requests[0].number;
const headSha = context.payload.workflow_run.head_sha;
const triggerWorkflow = context.payload.workflow_run.name;
console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`);
const checkRuns = await fetchCheckRuns(headSha);
console.log(`Found ${checkRuns.length} check runs`);
const { allComplete, anyFailed, results } = analyzeChecks(checkRuns);
for (const r of results) {
console.log(` ${r.status} ${r.name}`);
}
if (!allComplete) {
const pending = results.filter(r => !r.complete).length;
console.log(`⏳ ${pending}/${REQUIRED_CHECKS.length} checks pending`);
// Update to CHECKING status if checks are still running (prevents stale Ready/Failed status)
await updateStatusLabels(prNumber, STATUS_LABELS.CHECKING);
return;
}
const newLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
await updateStatusLabels(prNumber, newLabel);
const passedCount = results.filter(r => r.status === '✅ Passed').length;
const failedCount = results.filter(r => r.failed).length;
if (anyFailed) {
console.log(`❌ PR #${prNumber}: ${failedCount} check(s) failed`);
} else {
console.log(`✅ PR #${prNumber}: Ready for review (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
}
# ═══════════════════════════════════════════════════════════════════════════
# JOB 2: /check-status COMMAND
# Manual status check - anyone can trigger by commenting /check-status
# ═══════════════════════════════════════════════════════════════════════════
check-status-command:
name: Check Status Command
runs-on: ubuntu-latest
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/check-status')
timeout-minutes: 5
steps:
- name: Run status check and post report
uses: actions/github-script@v7
env:
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
// If label values change, update ALL occurrences: update-ci-status, check-status-command
const STATUS_LABELS = Object.freeze({
CHECKING: '🔄 Checking',
PASSED: '✅ Ready for Review',
FAILED: '❌ Checks Failed'
});
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
// If label values change, update ALL occurrences: check-status-command, update-review-status
const REVIEW_LABELS = Object.freeze([
'Missing AC Approval',
'AC: Approved',
'AC: Changes Requested',
'AC: Blocked',
'AC: Needs Re-review',
'AC: Reviewed'
]);
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
.split('\n')
.map(s => s.trim())
.filter(Boolean);
const { owner, repo } = context.repo;
const prNumber = context.payload.issue.number;
const requestedBy = context.payload.comment.user.login;
// Get PR details
const { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: prNumber
});
const headSha = pr.head.sha;
console.log(`PR #${prNumber} - /check-status by @${requestedBy}, SHA: ${headSha.slice(0, 8)}`);
// Fetch check runs with pagination to handle >100 checks
const checkRuns = await github.paginate(
github.rest.checks.listForRef,
{ owner, repo, ref: headSha, per_page: 100 },
(response) => response.data
);
console.log(`Found ${checkRuns.length} check runs`);
// Analyze results
const results = [];
let allComplete = true;
let anyFailed = false;
for (const checkName of REQUIRED_CHECKS) {
const check = checkRuns.find(c => c.name === checkName);
if (!check) {
results.push({ name: checkName, emoji: '⏳', complete: false });
allComplete = false;
} else if (check.status !== 'completed') {
results.push({ name: checkName, emoji: '🔄', complete: false });
allComplete = false;
} else if (check.conclusion === 'success') {
results.push({ name: checkName, emoji: '✅', complete: true });
} else if (check.conclusion === 'skipped') {
results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true });
} else {
results.push({ name: checkName, emoji: '❌', complete: true, failed: true });
anyFailed = true;
}
}
// Get current labels
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber
});
const labelNames = currentLabels.map(l => l.name);
const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None';
const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None';
// Update label if all checks complete
let newStatusLabel = STATUS_LABELS.CHECKING;
let statusChanged = false;
if (allComplete) {
newStatusLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
if (newStatusLabel !== currentStatusLabel) {
statusChanged = true;
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
for (const label of Object.values(STATUS_LABELS)) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
} catch (e) {
if (e && e.status !== 404) {
throw new Error(`Failed to remove label '${label}': ${e.message}`);
}
}
}
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newStatusLabel] });
}
}
// Build status report
const passedCount = results.filter(r => r.emoji === '✅').length;
let statusEmoji = '🔄';
if (allComplete && !anyFailed) statusEmoji = '✅';
else if (allComplete && anyFailed) statusEmoji = '❌';
const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n');
const lines = [
`## ${statusEmoji} PR Status Report`,
'',
`| Label | Value |`,
`|-------|-------|`,
`| CI Status | ${newStatusLabel} |`,
`| AC Review | ${currentReviewLabel} |`,
''
];
if (statusChanged) {
lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``);
lines.push('');
}
lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
lines.push('');
lines.push('| Status | Check |');
lines.push('|--------|-------|');
lines.push(checksTable);
lines.push('');
lines.push('---');
lines.push(`<sub>Triggered by \`/check-status\` from @${requestedBy}</sub>`);
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: lines.join('\n')
});
console.log(`✅ Posted status report to PR #${prNumber}`);
# ═══════════════════════════════════════════════════════════════════════════
# JOB 3: AUTO-CLAUDE REVIEW
# Processes Auto-Claude review comments from trusted sources
# Security: Only bots and collaborators can update labels
# ═══════════════════════════════════════════════════════════════════════════
update-review-status:
name: Update Review Status
runs-on: ubuntu-latest
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
!contains(github.event.comment.body, '/check-status')
timeout-minutes: 5
steps:
- name: Check for Auto-Claude review
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
// Security configuration
// SECURITY: Only [bot] suffixed accounts are protected by GitHub.
// Regular usernames can be registered by anyone and are NOT trusted.
const TRUSTED_BOT_ACCOUNTS = Object.freeze([
'github-actions[bot]',
'auto-claude[bot]'
]);
const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([
'COLLABORATOR',
'MEMBER',
'OWNER'
]);
const IDENTIFIER_PATTERNS = Object.freeze([
'🤖 Auto Claude PR Review',
'Auto Claude Review',
'Auto-Claude Review'
]);
// SECURITY: Regex patterns are tightened to prevent false matches
// Using \s* instead of .* and requiring specific emoji + verdict format
const VERDICTS = Object.freeze({
APPROVED: {
patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'],
// Match: "Merge Verdict:" followed by whitespace/emoji, then ✅, then APPROVED/READY TO MERGE
regex: /Merge Verdict:\s*✅\s*(?:APPROVED|READY TO MERGE)/i,
label: 'AC: Approved'
},
CHANGES_REQUESTED: {
patterns: ['NEEDS REVISION', 'Needs Revision'],
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🟠
regex: /Merge Verdict:\s*🟠/,
label: 'AC: Changes Requested'
},
BLOCKED: {
patterns: ['BLOCKED'],
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🔴
regex: /Merge Verdict:\s*🔴/,
label: 'AC: Blocked'
}
});
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
// If label values change, update ALL occurrences: check-status-command, update-review-status
const REVIEW_LABELS = Object.freeze([
'Missing AC Approval',
'AC: Approved',
'AC: Changes Requested',
'AC: Blocked',
'AC: Needs Re-review',
'AC: Reviewed'
]);
// Helper functions
// SECURITY: Verify both username AND account type to prevent spoofing
function isTrustedBot(username, userType) {
const isKnownBot = TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase());
// Only trust if it's a known bot account AND GitHub confirms it's a Bot type
return isKnownBot && userType === 'Bot';
}
function isTrustedAssociation(assoc) {
return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc);
}
function isAutoClaudeComment(body) {
return IDENTIFIER_PATTERNS.some(p => body.includes(p));
}
function parseVerdict(body) {
const safeBody = body.slice(0, 5000);
for (const [key, config] of Object.entries(VERDICTS)) {
const patternMatch = config.patterns.some(p => safeBody.includes(p));
const regexMatch = config.regex && config.regex.test(safeBody);
if (patternMatch || regexMatch) {
return { verdict: key, label: config.label };
}
}
return null;
}
async function updateReviewLabels(prNumber, newLabel) {
const { owner, repo } = context.repo;
// Remove all review labels first - throw on non-404 errors to prevent conflicting labels
for (const label of REVIEW_LABELS) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
console.log(` Removed: ${label}`);
} catch (e) {
if (e && e.status !== 404) {
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
throw new Error(`Failed to remove label '${label}': ${e.message}`);
}
}
}
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
console.log(` Added: ${newLabel}`);
} catch (e) {
if (e && e.status === 404) {
core.warning(`Label '${newLabel}' does not exist`);
} else {
throw e;
}
}
}
// Main logic
const prNumber = context.payload.issue.number;
const comment = context.payload.comment;
const commenter = comment.user.login;
const commenterType = comment.user.type;
const authorAssociation = comment.author_association;
const body = comment.body || '';
console.log(`PR #${prNumber} - Comment by: ${commenter} (type: ${commenterType}, assoc: ${authorAssociation})`);
// Security checks
// SECURITY: Bot status requires BOTH username match AND verified Bot type
const isBot = isTrustedBot(commenter, commenterType);
const isCollaborator = isTrustedAssociation(authorAssociation);
const isACComment = isAutoClaudeComment(body);
console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`);
if (!isBot && !isCollaborator) {
console.log('Skipping: Not a trusted bot or collaborator');
return;
}
if (!isACComment) {
console.log('Skipping: Not an Auto-Claude comment');
return;
}
const verdictResult = parseVerdict(body);
if (!verdictResult) {
console.log('Skipping: Could not parse verdict');
return;
}
console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`);
await updateReviewLabels(prNumber, verdictResult.label);
console.log(`✅ PR #${prNumber} review status updated`);
# ═══════════════════════════════════════════════════════════════════════════
# JOB 4: RE-REVIEW ON PUSH
# When new commits pushed after AC approval, require re-review
# ═══════════════════════════════════════════════════════════════════════════
require-re-review:
name: Require Re-review on Push
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
timeout-minutes: 5
steps:
- name: Check and reset AC approval if needed
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const pusher = context.payload.sender.login;
console.log(`PR #${prNumber} - New commits by: ${pusher}`);
// Get current labels
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber
});
const labelNames = labels.map(l => l.name);
// Check if PR was approved
const wasApproved = labelNames.includes('AC: Approved');
if (!wasApproved) {
console.log('PR was not AC-approved, no action needed');
return;
}
console.log('PR was AC-approved, resetting to require re-review');
// Remove AC: Approved - throw on non-404 errors to prevent conflicting labels
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'AC: Approved'
});
console.log(' Removed: AC: Approved');
} catch (e) {
if (e && e.status !== 404) {
// Throw to prevent adding 'AC: Needs Re-review' if removal failed (could cause conflicting labels)
core.error(`Failed to remove 'AC: Approved' label: ${e.message}`);
throw e;
}
}
// Add AC: Needs Re-review
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review']
});
console.log(' Added: AC: Needs Re-review');
} catch (e) {
if (e && e.status === 404) {
core.warning("Label 'AC: Needs Re-review' does not exist");
} else {
throw e;
}
}
// Post notification comment
const commentLines = [
'## 🔄 Re-review Required',
'',
'New commits were pushed after Auto-Claude approval.',
'',
'| Previous | Current |',
'|----------|---------|',
'| `AC: Approved` | `AC: Needs Re-review` |',
'',
'Please run Auto-Claude review again or request a manual review.',
'',
'---',
`<sub>Triggered by push from @${pusher}</sub>`
];
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: commentLines.join('\n')
});
console.log(`✅ Posted re-review notification to PR #${prNumber}`);
+14 -1
View File
@@ -29,10 +29,23 @@ jobs:
should_release: ${{ steps.check.outputs.should_release }}
new_version: ${{ steps.check.outputs.new_version }}
steps:
# Fail fast with clear error if PAT_TOKEN is not configured
- name: Validate PAT_TOKEN is configured
run: |
if [ -z "${{ secrets.PAT_TOKEN }}" ]; then
echo "::error::PAT_TOKEN secret is not configured."
echo "::error::This secret is required for automatic release triggering."
echo "::error::See https://github.com/AndyMik90/Auto-Claude/pull/1043 for setup instructions."
exit 1
fi
# IMPORTANT: Use PAT_TOKEN instead of GITHUB_TOKEN
# When GITHUB_TOKEN pushes a tag, it does NOT trigger other workflows (GitHub security feature)
# PAT_TOKEN allows the tag push to trigger release.yml automatically
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.PAT_TOKEN }}
- name: Get package version
id: package
+30 -31
View File
@@ -1,14 +1,29 @@
name: Quality Security
# CodeQL runs on all PRs, pushes to main, and weekly schedule
# Note: CodeQL takes 20-30 min per language (40-60 min total)
# Bandit is fast (5-10 min)
on:
push:
branches: [main, develop]
branches: [main]
paths:
- 'apps/**'
- 'tests/**'
- 'pyproject.toml'
- 'package.json'
- '.github/workflows/quality-security.yml'
pull_request:
branches: [main, develop]
paths:
- 'apps/**'
- 'tests/**'
- 'pyproject.toml'
- 'package.json'
- '.github/workflows/quality-security.yml'
schedule:
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
# Cancel in-progress runs for the same branch/PR
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -45,6 +60,7 @@ jobs:
with:
category: "/language:${{ matrix.language }}"
# Bandit runs on all PRs - it's fast (5-10 min)
python-security:
name: Python Security (Bandit)
runs-on: ubuntu-latest
@@ -65,8 +81,6 @@ jobs:
id: bandit
run: |
echo "::group::Running Bandit security scan"
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
@@ -80,7 +94,6 @@ jobs:
script: |
const fs = require('fs');
// Check if report exists
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan may have failed');
return;
@@ -89,38 +102,23 @@ jobs:
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
const results = report.results || [];
// Categorize by severity
const high = results.filter(r => r.issue_severity === 'HIGH');
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
const low = results.filter(r => r.issue_severity === 'LOW');
console.log(`::group::Bandit Security Scan Results`);
console.log(`Found ${results.length} issues:`);
console.log(` 🔴 HIGH: ${high.length}`);
console.log(` 🟡 MEDIUM: ${medium.length}`);
console.log(` 🟢 LOW: ${low.length}`);
console.log('');
// Print high severity issues
if (high.length > 0) {
console.log('High Severity Issues:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(` ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
console.log('');
}
}
console.log(` HIGH: ${high.length}`);
console.log(` MEDIUM: ${medium.length}`);
console.log(` LOW: ${low.length}`);
console.log('::endgroup::');
// Build summary
let summary = `## 🔒 Python Security Scan (Bandit)\n\n`;
let summary = `## Python Security Scan (Bandit)\n\n`;
summary += `| Severity | Count |\n`;
summary += `|----------|-------|\n`;
summary += `| 🔴 High | ${high.length} |\n`;
summary += `| 🟡 Medium | ${medium.length} |\n`;
summary += `| 🟢 Low | ${low.length} |\n\n`;
summary += `| High | ${high.length} |\n`;
summary += `| Medium | ${medium.length} |\n`;
summary += `| Low | ${low.length} |\n\n`;
if (high.length > 0) {
summary += `### High Severity Issues\n\n`;
@@ -134,14 +132,15 @@ jobs:
core.summary.addRaw(summary);
await core.summary.write();
// Fail if high severity issues found
if (high.length > 0) {
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('✅ No high severity security issues found');
console.log('No high severity security issues found');
}
# Summary job that waits for all security checks
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
security-summary:
name: Security Summary
runs-on: ubuntu-latest
@@ -160,7 +159,7 @@ jobs:
console.log(` CodeQL: ${codeql}`);
console.log(` Bandit: ${bandit}`);
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters, PR skipping CodeQL)
const acceptable = ['success', 'skipped'];
const codeqlOk = acceptable.includes(codeql);
const banditOk = acceptable.includes(bandit);
+132 -206
View File
@@ -1,5 +1,10 @@
name: Release
# Triggers on version tags (v*) to build and publish releases
#
# IMPORTANT: If branch protection is enabled on 'main', the update-readme job
# requires a PAT or GitHub App token with bypass permissions to push directly.
# Currently uses GITHUB_TOKEN which works if "Allow GitHub Actions to create
# and approve pull requests" is enabled OR branch protection is not configured.
on:
push:
@@ -10,7 +15,7 @@ on:
dry_run:
description: 'Test build without creating release'
required: false
default: true
default: false
type: boolean
jobs:
@@ -18,6 +23,9 @@ jobs:
# Note: macos-15-intel is the last Intel runner, supported until Fall 2027
build-macos-intel:
runs-on: macos-15-intel
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
@@ -26,23 +34,8 @@ jobs:
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Install Rust toolchain (for building native Python packages)
uses: dtolnay/rust-toolchain@stable
@@ -80,27 +73,13 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Notarize macOS Intel app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -115,6 +94,9 @@ jobs:
# Apple Silicon build on ARM64 runner for native compilation
build-macos-arm64:
runs-on: macos-15
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
@@ -123,23 +105,8 @@ jobs:
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Cache pip wheel cache
uses: actions/cache@v4
@@ -174,27 +141,13 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Notarize macOS ARM64 app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -222,24 +175,8 @@ jobs:
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
shell: bash
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Cache pip wheel cache
uses: actions/cache@v4
@@ -404,23 +341,8 @@ jobs:
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Flatpak
run: |
@@ -472,8 +394,50 @@ jobs:
apps/frontend/dist/*.yml
apps/frontend/dist/*.blockmap
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
finalize-notarization:
needs: [build-macos-intel, build-macos-arm64]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Download Intel DMG
uses: actions/download-artifact@v4
with:
name: macos-intel-builds
path: intel
- name: Download ARM64 DMG
uses: actions/download-artifact@v4
with:
name: macos-arm64-builds
path: arm64
- name: Wait for notarization and staple
uses: ./.github/actions/finalize-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
- name: Upload stapled Intel DMG
uses: actions/upload-artifact@v4
with:
name: macos-intel-stapled
path: intel/*.dmg
- name: Upload stapled ARM64 DMG
uses: actions/upload-artifact@v4
with:
name: macos-arm64-stapled
path: arm64/*.dmg
create-release:
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
needs: [build-macos-intel, build-macos-arm64, finalize-notarization, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -487,31 +451,74 @@ jobs:
with:
path: dist
- name: Flatten and validate artifacts
- name: Flatten binary artifacts
run: |
mkdir -p release-assets
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \;
# Validate that installer files exist (not just manifests)
# Copy stapled macOS DMGs (from finalize-notarization job)
# Validate that stapled DMGs exist before copying
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
else
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
fi
# Copy other macOS artifacts (zip, yml, blockmap) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Validate that installer files exist
installer_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
if [ "$installer_count" -eq 0 ]; then
echo "::error::No installer artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
exit 1
fi
echo "Found $installer_count installer(s):"
echo "Found $installer_count binary artifact(s):"
find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec basename {} \;
# Merge macOS manifests from Intel and ARM64 builds
# See: https://github.com/electron-userland/electron-builder/issues/5592
- name: Merge macOS manifests
uses: ./.github/actions/merge-macos-manifests
with:
dist-path: dist
output-path: release-assets
copy-other-manifests: 'true'
- name: Validate manifests
run: |
# Validate that electron-updater manifest files are present (required for auto-updates)
yml_count=$(find release-assets -type f -name "*.yml" | wc -l)
if [ "$yml_count" -eq 0 ]; then
echo "::error::No update manifest (.yml) files found! Auto-update architecture detection will not work."
echo "::error::No update manifest (.yml) files found! Auto-update will not work."
exit 1
fi
echo "Found $yml_count manifest file(s):"
find release-assets -type f -name "*.yml" -exec basename {} \;
# Validate required manifests exist
missing=""
[ ! -f "release-assets/latest-mac.yml" ] && missing="$missing latest-mac.yml"
[ ! -f "release-assets/latest.yml" ] && missing="$missing latest.yml"
[ ! -f "release-assets/latest-linux.yml" ] && missing="$missing latest-linux.yml"
if [ -n "$missing" ]; then
echo "::error::Missing required manifests:$missing"
echo "::error::Auto-update will fail on affected platforms!"
exit 1
fi
echo ""
echo "All required manifests present:"
echo " - latest-mac.yml (macOS)"
echo " - latest.yml (Windows)"
echo " - latest-linux.yml (Linux)"
echo ""
echo "All release assets:"
ls -la release-assets/
@@ -644,95 +651,14 @@ jobs:
- name: Update README.md
run: |
python3 << 'EOF'
import re
import sys
VERSION="${{ steps.version.outputs.version }}"
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
version = "${{ steps.version.outputs.version }}"
is_prerelease = "${{ steps.version.outputs.is_prerelease }}" == "true"
# Shields.io escapes hyphens as --
version_badge = version.replace("-", "--")
# Read README
with open("README.md", "r") as f:
content = f.read()
# Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10)
# Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin)
semver = r'\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
# Shields.io escaped pattern (hyphens as --)
semver_badge = r'\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
def update_section(text, start_marker, end_marker, replacements):
"""Update content between markers with given replacements."""
pattern = f'({re.escape(start_marker)})(.*?)({re.escape(end_marker)})'
def replace_section(match):
section = match.group(2)
for old_pattern, new_value in replacements:
section = re.sub(old_pattern, new_value, section)
return match.group(1) + section + match.group(3)
return re.sub(pattern, replace_section, text, flags=re.DOTALL)
if is_prerelease:
print(f"Updating BETA section to {version} (badge: {version_badge})")
# Update beta badge
content = re.sub(
rf'beta-{semver_badge}-orange',
f'beta-{version_badge}-orange',
content
)
# Update beta version badge link
content = update_section(content,
'<!-- BETA_VERSION_BADGE -->', '<!-- BETA_VERSION_BADGE_END -->',
[(rf'tag/v{semver}\)', f'tag/v{version})')])
# Update beta downloads
content = update_section(content,
'<!-- BETA_DOWNLOADS -->', '<!-- BETA_DOWNLOADS_END -->',
[
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
(rf'download/v{semver}/', f'download/v{version}/'),
])
else:
print(f"Updating STABLE section to {version} (badge: {version_badge})")
# Update top version badge
content = update_section(content,
'<!-- TOP_VERSION_BADGE -->', '<!-- TOP_VERSION_BADGE_END -->',
[
(rf'version-{semver_badge}-blue', f'version-{version_badge}-blue'),
(rf'tag/v{semver}\)', f'tag/v{version})'),
])
# Update stable badge
content = re.sub(
rf'stable-{semver_badge}-blue',
f'stable-{version_badge}-blue',
content
)
# Update stable version badge link
content = update_section(content,
'<!-- STABLE_VERSION_BADGE -->', '<!-- STABLE_VERSION_BADGE_END -->',
[(rf'tag/v{semver}\)', f'tag/v{version})')])
# Update stable downloads
content = update_section(content,
'<!-- STABLE_DOWNLOADS -->', '<!-- STABLE_DOWNLOADS_END -->',
[
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
(rf'download/v{semver}/', f'download/v{version}/'),
])
# Write updated README
with open("README.md", "w") as f:
f.write(content)
print(f"README.md updated for {version} (prerelease={is_prerelease})")
EOF
if [ "$IS_PRERELEASE" = "true" ]; then
python3 scripts/update-readme.py "$VERSION" --prerelease
else
python3 scripts/update-readme.py "$VERSION"
fi
echo "--- Verifying update ---"
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
-63
View File
@@ -1,63 +0,0 @@
name: Test on Tag
on:
push:
tags:
- 'v*'
jobs:
# Python tests
test-python:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.12', '3.13']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --tb=short
# Frontend tests
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Run tests
working-directory: apps/frontend
run: npm run test
-71
View File
@@ -1,71 +0,0 @@
name: Validate Version
on:
push:
tags:
- 'v*'
jobs:
validate-version:
name: Validate package.json version matches tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from tag
id: tag_version
run: |
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
echo "Tag version: $TAG_VERSION"
- name: Extract version from package.json
id: package_version
run: |
# Read version from package.json
PACKAGE_VERSION=$(node -p "require('./apps/frontend/package.json').version")
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "Package.json version: $PACKAGE_VERSION"
- name: Compare versions
run: |
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
echo "=========================================="
echo "Version Validation"
echo "=========================================="
echo "Git tag version: v$TAG_VERSION"
echo "package.json version: $PACKAGE_VERSION"
echo "=========================================="
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo ""
echo "❌ ERROR: Version mismatch detected!"
echo ""
echo "The version in package.json ($PACKAGE_VERSION) does not match"
echo "the git tag version ($TAG_VERSION)."
echo ""
echo "To fix this:"
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
echo " 2. Update package.json version to $TAG_VERSION"
echo " 3. Commit the change"
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
echo ""
echo "Or use the automated script:"
echo " node scripts/bump-version.js $TAG_VERSION"
echo ""
exit 1
fi
echo ""
echo "✅ SUCCESS: Versions match!"
echo ""
- name: Version validation result
if: success()
run: |
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
+6 -2
View File
@@ -56,6 +56,7 @@ lerna-debug.log*
# Auto Claude Generated
# ===========================
.auto-claude/
.planning/
.auto-build-security.json
.auto-claude-security.json
.auto-claude-status
@@ -107,7 +108,8 @@ dmypy.json
# ===========================
# Node.js (apps/frontend)
# ===========================
node_modules/
node_modules
apps/frontend/node_modules
.npm
.yarn/
.pnp.*
@@ -168,4 +170,6 @@ OPUS_ANALYSIS_AND_IDEAS.md
# Auto Claude generated files
.security-key
/shared_docs
/shared_docs
logs/security/
Agents.md
+140 -43
View File
@@ -1,11 +1,52 @@
#!/bin/sh
# Preserve git worktree context - prevent HEAD corruption in worktrees
# =============================================================================
# GIT WORKTREE CONTEXT HANDLING
# =============================================================================
# When running in a worktree, we need to preserve git context to prevent HEAD
# corruption. However, we must also CLEAR these variables when NOT in a worktree
# to prevent cross-worktree contamination (files leaking between worktrees).
#
# The bug: If GIT_DIR/GIT_WORK_TREE are set from a previous worktree session
# and this hook runs in the main repo (where .git is a directory, not a file),
# git commands will target the wrong repository, causing files to appear as
# untracked in the wrong location.
#
# Fix: Explicitly unset these variables when NOT in a worktree context.
# =============================================================================
if [ -f ".git" ]; then
WORKTREE_GIT_DIR=$(cat .git | sed 's/gitdir: //')
if [ -n "$WORKTREE_GIT_DIR" ]; then
# We're in a worktree (.git is a file pointing to the actual git dir)
# Use -n with /p to only print lines that match the gitdir: prefix, head -1 for safety
WORKTREE_GIT_DIR=$(sed -n 's/^gitdir: //p' .git | head -1)
if [ -n "$WORKTREE_GIT_DIR" ] && [ -d "$WORKTREE_GIT_DIR" ]; then
export GIT_DIR="$WORKTREE_GIT_DIR"
export GIT_WORK_TREE="$(pwd)"
else
# .git file exists but is malformed or points to non-existent directory
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
unset GIT_DIR
unset GIT_WORK_TREE
fi
else
# We're in the main repo (.git is a directory)
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
unset GIT_DIR
unset GIT_WORK_TREE
fi
# =============================================================================
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
# =============================================================================
# If core.worktree is set in the main repo's config (pointing to a worktree),
# this indicates previous corruption. Fix it automatically.
if [ ! -f ".git" ]; then
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
if [ -n "$CORE_WORKTREE" ]; then
echo "Warning: Detected corrupted core.worktree setting, removing it..."
if ! git config --unset core.worktree 2>/dev/null; then
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
fi
fi
fi
@@ -62,8 +103,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
perl -i -pe 'if (/<!-- BETA_DOWNLOADS -->/ .. /<!-- BETA_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
done
else
# STABLE: Update stable sections and top badge
@@ -78,8 +120,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
perl -i -pe 'if (/<!-- STABLE_DOWNLOADS -->/ .. /<!-- STABLE_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
done
fi
@@ -140,18 +183,31 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
(
cd apps/backend
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py"
if [ -d ".venv" ]; then
# Use venv if it exists
if [ -f ".venv/bin/pytest" ]; then
PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
elif [ -f ".venv/Scripts/pytest.exe" ]; then
# Windows
PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
# Also skip tests that require optional dependencies (pydantic structured outputs)
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py --ignore=../../tests/test_finding_validation.py --ignore=../../tests/test_sdk_structured_output.py --ignore=../../tests/test_structured_outputs.py"
# Determine Python executable from venv
VENV_PYTHON=""
if [ -f ".venv/bin/python" ]; then
VENV_PYTHON=".venv/bin/python"
elif [ -f ".venv/Scripts/python.exe" ]; then
VENV_PYTHON=".venv/Scripts/python.exe"
fi
if [ -n "$VENV_PYTHON" ]; then
# Check if pytest is installed in venv
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
else
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
echo "Warning: pytest not installed in venv. Installing test dependencies..."
$VENV_PYTHON -m pip install -q -r ../../tests/requirements-test.txt
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
fi
elif [ -d ".venv" ]; then
echo "Warning: venv exists but Python not found in it, using system Python"
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
else
echo "Warning: No .venv found in apps/backend, using system Python"
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
fi
)
@@ -170,45 +226,86 @@ fi
# Check if there are staged files in apps/frontend
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
echo "Frontend changes detected, running frontend checks..."
# Use subshell to isolate directory changes and prevent worktree corruption
(
cd apps/frontend
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
if [ $? -ne 0 ]; then
echo "lint-staged failed. Please fix linting errors before committing."
exit 1
fi
# Detect if we're in a worktree and check if dependencies are available
IS_WORKTREE=false
DEPS_AVAILABLE=true
# Run TypeScript type check
echo "Running type check..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "Type check failed. Please fix TypeScript errors before committing."
if [ -f ".git" ]; then
# .git is a file (not directory) in worktrees
IS_WORKTREE=true
echo "Detected git worktree environment"
fi
# Check if node_modules has actual dependencies by looking for a known package
# @lydell/node-pty is required for terminal code and is a common source of TypeScript errors
# It may be in root node_modules (hoisted) or apps/frontend/node_modules
# Note: -d follows symlinks automatically, so this works for both real dirs and symlinks
# We check for the full package path (@lydell/node-pty) rather than just the namespace
# for precise detection - ensures the actual dependency is installed, not just any @lydell package
if [ ! -d "node_modules/@lydell/node-pty" ] && [ ! -d "apps/frontend/node_modules/@lydell/node-pty" ]; then
DEPS_AVAILABLE=false
fi
if [ "$DEPS_AVAILABLE" = false ]; then
if [ "$IS_WORKTREE" = true ]; then
# In worktree without dependencies - warn but allow commit
echo ""
echo "⚠️ WARNING: node_modules not available in this worktree."
echo " TypeScript and lint checks will be skipped."
echo " This is expected for auto-claude worktrees."
echo " Full validation will occur when PR is created/merged."
echo ""
else
# Main repo without dependencies - this is an error
echo "Error: node_modules not found. Run 'npm install' first."
exit 1
fi
else
# Dependencies available - run full frontend checks
# Use subshell to isolate directory changes and prevent worktree corruption
(
cd apps/frontend
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
if [ $? -ne 0 ]; then
echo "lint-staged failed. Please fix linting errors before committing."
exit 1
fi
# Run linting
echo "Running lint..."
npm run lint
if [ $? -ne 0 ]; then
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
exit 1
fi
# Run TypeScript type check
echo "Running type check..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "Type check failed. Please fix TypeScript errors before committing."
exit 1
fi
# Run linting
echo "Running lint..."
npm run lint
if [ $? -ne 0 ]; then
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
exit 1
fi
# Check for vulnerabilities (only high severity)
echo "Checking for vulnerabilities..."
npm audit --audit-level=high
# Check for vulnerabilities (only critical severity)
# Note: Using critical level because electron-builder has a known high-severity
# tar vulnerability (CVE-2026-23745) that cannot be fixed until electron-builder
# releases an update with [email protected] support. This is a build dependency, not runtime.
echo "Checking for vulnerabilities..."
npm audit --audit-level=critical
if [ $? -ne 0 ]; then
echo "Critical severity vulnerabilities found. Run 'npm audit fix' to resolve."
exit 1
fi
)
if [ $? -ne 0 ]; then
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
exit 1
fi
)
if [ $? -ne 0 ]; then
exit 1
echo "Frontend checks passed!"
fi
echo "Frontend checks passed!"
fi
echo "All pre-commit checks passed!"
+18 -7
View File
@@ -76,6 +76,17 @@ repos:
files: ^package\.json$
pass_filenames: false
# Python encoding check - prevent regression of UTF-8 encoding fixes (PR #782)
- repo: local
hooks:
- id: check-file-encoding
name: Check file encoding parameters
entry: python scripts/check_encoding.py
language: system
types: [python]
files: ^apps/backend/
description: Ensures all file operations specify encoding="utf-8"
# Python linting (apps/backend/)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10
@@ -126,24 +137,24 @@ repos:
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
pass_filenames: false
# Frontend linting (apps/frontend/)
# Frontend linting (apps/frontend/) - Biome is 15-25x faster than ESLint
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
- repo: local
hooks:
- id: eslint
name: ESLint
- id: biome
name: Biome (lint + format)
entry: bash
args:
- -c
- |
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
# Skip in worktrees if node_modules doesn't exist (Biome not installed)
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
echo "Skipping ESLint in worktree (node_modules not found)"
echo "Skipping Biome in worktree (node_modules not found)"
exit 0
fi
cd apps/frontend && npm run lint
cd apps/frontend && npx biome check --write --no-errors-on-unmatched .
language: system
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
files: ^apps/frontend/.*\.(ts|tsx|js|jsx|json)$
pass_filenames: false
- id: typecheck
+391
View File
@@ -1,3 +1,394 @@
## 2.7.5 - Security & Platform Improvements
### ✨ New Features
- One-time version 2.7.5 reauthentication warning modal for improved security awareness
- Enhanced authentication failure detection and handling with improved error recovery
- PR review validation pipeline with context enrichment and cross-validation support
- Terminal "Others" section in worktree dropdown for better organization
- Keyboard shortcut to toggle terminal expand/collapse for improved usability
- Searchable branch combobox in worktree creation dialog for easier branch selection
- Update Branch button in PR detail view for streamlined workflow
- Bulk select and create PR functionality for human review column
- Draggable Kanban task reordering for flexible task management
- YOLO mode to invoke Claude with --dangerously-skip-permissions for advanced users
- File and screenshot upload to QA feedback interface for better feedback submission
- Task worktrees section with terminal limit removal for expanded parallel work
- Claude Code version rollback feature for version management
- Linux secret-service support for OAuth token storage (ACS-293)
### 🛠️ Improvements
- Replace setup-token with embedded /login terminal flow for streamlined authentication
- Refactored authentication using platform abstraction for cross-platform reliability
- Removed redundant backend CLI detection (~230 lines) for cleaner codebase
- Replaced Select with Combobox for branch selection UI improvements
- Replace dangerouslySetInnerHTML with Trans component for better security practice
- Wait for CI checks before starting AI PR review for more accurate results
- Improved Claude CLI detection with installation selector
- Terminal rendering, persistence, and link handling improvements
- Enhanced terminal recreation logic with retry mechanism for reliability
- Improved worktree name input UX with better validation
- Made worktree isolation prominent in UI for user awareness
- Reduce ultrathink value from 65536 to 60000 for Opus 4.5 compatibility
- Standardized workflow naming and consolidated linting workflow
- Added gate jobs to CI/CD pipeline for better quality control
- Fast-path detection for merge commits without finding overlap in PR review
- Show progress percentage during planning phase on task cards
- PTY write improvements using PtyManager.writeToPty for safer terminal operations
- Consolidated package-lock.json to root level for simpler dependency management
- Graphiti memory feature fixes on macOS
- Model versions updated to Claude 4.5 with connected insights to frontend settings
### 🐛 Bug Fixes
- Fixed Kanban board status flip-flopping and multi-location task deletion
- Fixed Windows CLI detection and version selection UX issues
- Fixed Windows coding phase not starting after spec/planning
- Fixed Windows UTF-8 encoding errors across entire backend (251 instances)
- Fixed 401 authentication errors by reading tokens from profile configDir
- Fixed Windows packaging by using SDK bundled Claude CLI
- Fixed false stuck detection during planning phase
- Fixed PR list update on post status click
- Fixed screenshot state persistence bug in task modals
- Fixed non-functional '+ Add' button for multiple Claude accounts
- Fixed GitHub Issues/PRs infinite scroll auto-fetch behavior
- Fixed GitHub PR state management and follow-up review trigger bug
- Fixed terminal output freezing on project switch
- Fixed terminal rendering on app close to prevent zombie processes
- Fixed stale terminal metadata filtering with auto-cleanup
- Fixed worktree configuration sync after PTY creation
- Fixed cross-worktree file leakage via environment variables
- Fixed .gitignore auto-commit during project initialization
- Fixed PR review verdict message contradiction and blocked status limbo
- Fixed re-review functionality when previous review failed
- Fixed agent profile resolution before falling back to defaults
- Fixed Windows shell command support in Claude CLI invocation
- Fixed model resolution using resolve_model_id() instead of hardcoded fallbacks
- Fixed ultrathink token budget correction from 64000 to 63999
- Fixed Windows pywin32 DLL loading failure on Python 3.8+
- Fixed circular import between spec.pipeline and core.client
- Fixed pywin32 bundling in Windows binary
- Fixed secretstorage bundling in Linux binary
- Fixed gh CLI detection for PR creation
- Fixed PYTHONPATH isolation to prevent pollution of external projects
- Fixed structured output capture from SDK ResultMessage in PR review
- Fixed CI status refresh before returning cached verdict
- Fixed Python environment readiness before spawning tasks
- Fixed pywintypes import errors during dependency validation
- Fixed Node.js and npm path detection on Windows packaged apps
- Fixed Windows PowerShell command separator usage
- Fixed require is not defined error in terminal handler
- Fixed Sentry DSN initialization error handling
- Fixed requestAnimationFrame fallback for flaky Ubuntu CI tests
- Fixed file drag-and-drop to terminals and task modals with branch status refresh
- Fixed GitHub issues pagination and infinite scroll
- Fixed delete worktree status regression
- Fixed Mac crash on Invoke Claude button
- Fixed worktree symlink for node_modules to enable TypeScript support
- Fixed PTY wait on Windows before recreating terminal
- Fixed terminal aggressive renaming on Claude invocation
- Fixed worktree dropdown scroll area to prevent overflow
- Fixed GitHub PR preloading currently under review
- Fixed actual base branch name display instead of hardcoded main
- Fixed Claude CLI detection with improved installation selector
- Fixed broken pipe errors with Sentry integration
- Fixed app update persistence for Install button visibility
- Fixed Claude exit detection and label reset
- Fixed file merging to include files with content changes
- Fixed worktree config sync on terminal restoration
- Fixed security profile inheritance in worktrees and shell -c validation
- Fixed terminal drag and drop reordering collision detection
- Fixed "already up to date" case handling in worktree operations
- Fixed Windows UTF-8 encoding and path handling issues
- Fixed Terminal label persistence after app restart
- Fixed worktree dropdown enhancement with scrolling support
- Fixed enforcement of 12 terminal limit per project
- Fixed macOS UTF-8 encoding errors (251 instances)
### 📚 Documentation
- Added fork configuration guidance to CONTRIBUTING.md
- Updated README download links to v2.7.4
### 🔧 Other Changes
- Removed node_modules symlink and cleaned up package-lock.json
- Added .planning/ to gitignore
- Migrated ESLint to Biome with optimized workflows
- Fixed tar vulnerability in dependencies
- Added minimatch to externalized dependencies
- Added exception handling for malformed DSN during Sentry initialization
- Corrected roadmap import path in roadmap_runner.py
- Added require polyfill for ESM/Sentry compatibility
- Addressed CodeQL security alerts and code quality issues
- Added shell: true and argument sanitization for Windows packaging
- Packaged runtime dependencies with pydantic_core validation
---
## What's Changed
- test(subprocess): add comprehensive auth failure detection tests by @AndyMik90 in ccaf82db
- fix(security): replace dangerouslySetInnerHTML with Trans component and persist version warning by @AndyMik90 in 7aec35c3
- chore: remove node_modules symlink and clean up package-lock.json by @AndyMik90 in 9768af8e
- fix: address PR review issues and improve code quality by @AndyMik90 in 23a7e5a2
- fix(auth): read tokens from profile configDir to fix 401 errors (#1385) by @Andy in 55857d6d
- fix: Kanban board status flip-flopping and multi-location task deletion (#1387) by @Adam Slaker in 7dcb7bbe
- fix(windows): use SDK bundled Claude CLI for Windows packaged apps (#1382) by @Andy in cd4e2d38
- feat(auth): enhance authentication failure detection and handling by @AndyMik90 in 7ab10cd5
- refactor(subprocess): use platform abstraction for auth failure process killing by @AndyMik90 in 17cffecc
- feat(ui): add one-time version 2.7.5 reauthentication warning modal by @AndyMik90 in f49ef92a
- refactor: remove redundant backend CLI detection (~230 lines) (#1367) by @Andy in c7bc01d5
- feat(pr-review): add validation pipeline, context enrichment, and cross-validation (#1354) by @Andy in d8f4de9a
- fix(terminal): rename Claude terminals only once on initial message (#1366) by @Andy in b2d2d7e9
- feat(auth): add auth failure detection modal for Claude CLI 401 errors (#1361) by @Andy in 317d5e94
- docs: add fork configuration guidance to CONTRIBUTING.md (#1364) by @Andy in c57534c3
- Fix #609: Windows coding phase not starting after spec/planning (#1347) by @TamerineSky in 6da1b170
- Fix Windows UTF-8 encoding errors across entire backend (251 instances) (#782) by @TamerineSky in 6a6247bb
- chore: add .planning/ to gitignore by @AndyMik90 in 8df66245
- feat(auth): replace setup-token with embedded /login terminal flow (#1321) by @Andy in 11f8d572
- fix: Windows CLI detection and version selection UX improvements (#1341) by @StillKnotKnown in 8a2f3acd
- fix: add shell: true and argument sanitization for Windows packaging (#1340) by @StillKnotKnown in e482fdf1
- fix: package runtime deps and validate pydantic_core (#1336) by @StillKnotKnown in 141f44f6
- fix(test): update mock profile manager and relax audit level by @Test User in 86ba0246
- 2.7.4 release stable by @Test User in 3e2d6ef4
- fix(tests): update claude-integration-handler tests for PtyManager.writeToPty by @Test User in 56743ff7
- chore: consolidate package-lock.json to root level by @Test User in d4044d26
- build: add minimatch to externalized dependencies by @Test User in 95f7f222
- refactor(terminal): use PtyManager.writeToPty for safer PTY writes by @Test User in 4637a1a9
- fix: correct ultrathink token budget from 64000 to 63999 by @Test User in efdb8c71
- ci: migrate ESLint to Biome, optimize workflows, fix tar vulnerability (#1289) by @Andy in 0b2cf9b0
- Fix API 401 - Token Decryption Before SDK Initialization (#1283) by @Andy in 4b740928
- Fix Ultrathink Token Limit Bug (#1284) by @Andy in e989300b
- fix(security): address CodeQL security alerts and code quality issues (#1286) by @Andy in f700b18d
- fix(ui): make prose-invert conditional on dark mode for light theme support (#1160) by @youngmrz in 439ed86a
- fix(terminal): add require polyfill for ESM/Sentry compatibility (#1275) by @VDT-91 in eb739afe
- fix: add retry logic for planning-to-coding transition (#1276) by @kaigler in b8655904
- fix(worktree): prevent cross-worktree file leakage via environment variables (#1267) by @Andy in 7cb9e0a3
- Fix/cleanup 2.7.5 (#1271) by @Andy in f0c3e508
- Fix False Stuck Detection During Planning Phase (#1236) by @Andy in 44304a61
- fix(pr-review): allow re-review when previous review failed (#1268) by @Andy in 4cc8f4db
- fix: enforce 12 terminal limit per project (#1264) by @Andy in d7ed770e
- Draggable Kanban Task Reordering (#1217) by @Andy in 3606a632
- fix(terminal): sync worktree config after PTY creation to fix first-attempt failure (#1213) by @Andy in 39236f18
- fix: auto-commit .gitignore changes during project initialization (#1087) (#1124) by @youngmrz in ba089c5b
- Fix terminal rendering, persistence, and link handling (#1215) by @Andy in 75a3684c
- fix(windows): prevent zombie process accumulation on app close (#1259) by @VDT-91 in 90204469
- update gitignore by @AndyMik90 in c13d9a40
- Fix PR List Update on Post Status Click (#1207) by @Andy in 3085e392
- Fix screenshot state persistence bug in task modals (#1235) by @Andy in 3024d547
- Fix non-functional '+ Add' button for multiple Claude accounts (#1216) by @Andy in e27ff344
- Fix GitHub Issues/PRs Infinite Scroll Auto-Fetch (#1239) by @Andy in b74b628b
- Add bulk delete functionality to worktree overview (#1208) by @Andy in 8833feb2
- Fix GitHub PR State Management - Follow-up Review Trigger Bug (#1238) by @Andy in 76f07720
- auto-claude: subtask-1-1 - Add useEffect hook to reset expandedTerminalId when projectPath changes (#1240) by @Andy in d1131080
- Fix Terminal Output Freezing on Project Switch (#1241) by @Andy in 193d2ed9
- Add Update Branch Button to PR Detail View (#1242) by @Andy in 87c84073
- Bulk Select All & Create PR for Human Review Column (#1248) by @Andy in 715202b8
- fix(windows): resolve pywin32 DLL loading failure on Python 3.8+ (#1244) by @VDT-91 in cb786cac
- fix(gh-cli): use get_gh_executable() and pass GITHUB_CLI_PATH from GUI (ACS-321) (#1232) by @StillKnotKnown in 14fbc2eb
- auto-claude: subtask-1-1 - Replace Select with Combobox for branch selection (#1250) by @Andy in ed45ece5
- fix(sentry): add exception handling for malformed DSN during Sentry initialization by @AndyMik90 in 4f86742b
- dev dependecnies using npm install all by @AndyMik90 in e52a1ba4
- hotfix/dev-dependency-missing by @AndyMik90 in a0033b1e
- fix(frontend): resolve require is not defined error in terminal handler (#1243) by @Antti in 9117b59e
- hotfix/node by @AndyMik90 in bb620044
- fix(windows): add Node.js and npm paths to COMMON_BIN_PATHS for packaged apps (#1158) by @youngmrz in f0319bc8
- fix/stale-task-creation by @AndyMik90 in 9612cf8d
- fix/sentry-local-build by @AndyMik90 in b822797f
- hotfix/tar-vurnability by @AndyMik90 in 2096b0e2
- fix(tests): add requestAnimationFrame fallback for flaky Ubuntu CI tests by @AndyMik90 in 9739b338
- fix(windows): use correct command separator for PowerShell terminals (#1159) by @youngmrz in cb8e46ca
- fix(ui): show progress percentage during planning phase on task cards (#1162) by @youngmrz in 515aada1
- fix(tests): isolate git operations in test fixtures from parent repository (#1205) by @Andy in 596b1e0c
- feat(terminal): add "Others" section to worktree dropdown (#1209) by @Andy in 219cc068
- fix(linux): ensure secretstorage is bundled in Linux binary (ACS-310) (#1211) by @StillKnotKnown in 48bd4a9c
- fix(terminal): persist worktree label after app restart (#1210) by @Andy in ba7358af
- fix: Graphiti memory feature on macOS (#1174) by @Alexander Penzin in c2e53d58
- fix(windows): ensure pywin32 is bundled in Windows binary (ACS-306) (#1197) by @StillKnotKnown in 76af0aaa
- fix(spec): resolve circular import between spec.pipeline and core.client (ACS-302) (#1192) by @StillKnotKnown in 648cf3fc
- Fix Mac Crash on Invoke Claude Button (#1185) by @Andy in ae40f819
- fix(worktree): symlink node_modules to worktrees for TypeScript support (#1148) by @Andy in d7c7ce8e
- fix(terminal): wait for PTY exit on Windows before recreating terminal (#1184) by @Andy in d5d56975
- fix(runners): use resolve_model_id() for model resolution instead of hardcoded fallbacks (ACS-294) (#1170) by @StillKnotKnown in 5199fdbf
- fix(frontend): support Windows shell commands in Claude CLI invocation (ACS-261) (#1152) by @StillKnotKnown in 3a1966bd
- feat(terminal): add keyboard shortcut to toggle expand/collapse (#1180) by @Andy in 1edfe333
- fix(kanban): remove error column and add backend JSON repair (#1143) by @Andy in 51f67c5d
- fix(ci): add gate jobs and consolidate linting workflow (#1182) by @Andy in 4b43f074
- fix(ci): standardize workflow naming and remove redundant workflows (#1178) by @Andy in 4a3391b2
- fix(terminal): enable scrolling in worktree dropdown when many items exist (#1175) by @Andy in 5525f36d
- fix: windows (#1056) by @Alex in d6234f52
- fix(backend): reduce ultrathink value from 65536 to 60000 for Opus 4.5 compatibility (#1173) by @StillKnotKnown in 30638c2f
- feat(backend): add Linux secret-service support for OAuth token storage (ACS-293) (#1168) by @StillKnotKnown in a6934a8e
- fix(terminal): prevent aggressive renaming on Claude invocation (#1147) by @Andy in 10bceac9
- fix(pr-review): resolve verdict message contradiction and blocked status limbo (#1151) by @Andy in 8b269fea
- feat(pr-review): add fast-path detection for merge commits without finding overlap (#1145) by @Andy in 32811142
- fix(frontend): resolve agent profile before falling back to defaults (ACS-255) (#1068) by @StillKnotKnown in 33014682
- fix(terminal): add scroll area to worktree dropdown to prevent overflow (#1146) by @Andy in 200bb3bc
- fix(frontend): add windowsVerbatimArguments for Windows .cmd validation (ACS-252) (#1075) by @StillKnotKnown in 658f26cb
- fix(backend): improve gh CLI detection for PR creation (ACS-247) (#1071) by @StillKnotKnown in 2eef82bf
- fix(terminal): filter stale worktree metadata and auto-cleanup (#1038) by @Andy in 16bc37ce
- Fix Delete Worktree Status Regression (#1076) by @Andy in 97f98ed7
- 117-sidebar-update-banner (#1078) by @Andy in 4fd25b01
- fix(ci): add beta manifest renaming and validation (#1002) (#1080) by @Andy in c6c6525b
- fix: update all model versions to Claude 4.5 and connect insights to frontend settings (#1082) by @Andy in 58f4f30b
- fix: file drag-and-drop to terminals and task modals + branch status refresh (#1092) by @Andy in b5c0e631
- fix(github-issues): add pagination and infinite scroll for issues tab (#1042) by @Andy in f1674923
- fix(ci): enable automatic release workflow triggering (#1043) by @Andy in 2ff9ccab
- fix(backend): isolate PYTHONPATH to prevent pollution of external projects (ACS-251) (#1065) by @StillKnotKnown in 18d9b6cf
- add time sensitive AI review logic (#1137) by @Andy in 5fb7574b
- fix(pr-review): use list instead of tuple for line_range to fix SDK structured output (#1140) by @Andy in 45060ca3
- feat(github-review): wait for CI checks before starting AI PR review (#1131) by @Andy in a55e4f68
- fix(frontend): pass CLAUDE_CLI_PATH to Python backend subprocess (ACS-230) (#1081) by @StillKnotKnown in 5e91c3a7
- fix(runners): correct roadmap import path in roadmap_runner.py (ACS-264) (#1091) by @StillKnotKnown in 767dd5c3
- fix(pr-review): properly capture structured output from SDK ResultMessage (#1133) by @Andy in f28d2298
- fix(github-review): refresh CI status before returning cached verdict (#1083) by @Andy in c3bdd4f8
- fix(agent): ensure Python env is ready before spawning tasks (ACS-254) (#1061) by @StillKnotKnown in 7dc54f23
- fix(windows): prevent pywintypes import errors before dependency validation (ACS-253) (#1057) by @StillKnotKnown in 71a9fc84
- fix(docs): update README download links to v2.7.4 by @Test User in 67b39e52
- fix readme for 2.7.4 by @Test User in a0800646
- changelog 2.7.4 by @AndyMik90 in 1b5aecdd
- 2.7.4 release by @AndyMik90 in 72797ac0
- fix(frontend): validate Windows claude.cmd reliably in GUI (#1023) by @Umaru in 1ae3359b
- fix(auth): await profile manager initialization before auth check (#1010) by @StillKnotKnown in c8374bc1
- Add file/screenshot upload to QA feedback interface (#1018) by @Andy in 88277f84
- feat(terminal): add task worktrees section and remove terminal limit (#1033) by @Andy in 17118b07
- fix(terminal): enhance terminal recreation logic with retry mechanism (#1013) by @Andy in df1b8a3f
- fix(terminal): improve worktree name input UX (#1012) by @Andy in 54e9f228
- Make worktree isolation prominent in UI (#1020) by @Andy in 4dbb7ee4
- feat(terminal): add YOLO mode to invoke Claude with --dangerously-skip-permissions (#1016) by @Andy in d48e5f68
- Fix Duplicate Kanban Task Creation on Rapid Button Clicks (#1021) by @Andy in 2d1d3ef1
- feat(sentry): embed Sentry DSN at build time for packaged apps (#1025) by @Andy in aed28c5f
- fix(github): resolve circular import issues in context_gatherer and services (#1026) by @Andy in 0307a4a9
- hotfix/sentry-backend-build by @AndyMik90 in e7b38d49
- chore: bump version to 2.7.4 by @AndyMik90 in 432e985b
- fix(github-prs): prevent preloading of PRs currently under review (#1006) by @Andy in 1babcc86
- fix(ui): display actual base branch name instead of hardcoded main (#969) by @Andy in 5d07d5f1
- ci(release): move VirusTotal scan to separate post-release workflow (#980) by @Andy in 553d1e8d
- fix: improve Claude CLI detection and add installation selector (#1004) by @Andy in e07a0dbd
- fix(backend): add Sentry integration and fix broken pipe errors (#991) by @Andy in aa9fbe9d
- fix(app-update): persist downloaded update state for Install button visibility (#992) by @Andy in 6f059bb5
- fix(terminal): detect Claude exit and reset label when user closes Claude (#990) by @Andy in 14982e66
- fix(merge): include files with content changes even when semantic analysis is empty (#986) by @Andy in 4736b6b6
- fix(frontend): sync worktree config to renderer on terminal restoration (#982) by @Andy in 68fe0860
- feat(frontend): add searchable branch combobox to worktree creation dialog (#979) by @Andy in 2a2dc3b8
- fix(security): inherit security profiles in worktrees and validate shell -c commands (#971) by @Andy in 750ea8d1
- feat(frontend): add Claude Code version rollback feature (#983) by @Andy in 8d21978f
- fix(ACS-181): enable auto-switch on 401 auth errors & OAuth-only profiles (#900) by @Michael Ludlow in e7427321
- fix(terminal): add collision detection for terminal drag and drop reordering (#985) by @Andy in 1701160b
- fix(worktree): handle "already up to date" case correctly (ACS-226) (#961) by @StillKnotKnown in 74ed4320
- ci: add Azure auth test workflow by @AndyMik90 in d12eb523
## Thanks to all contributors
@AndyMik90, @Andy, @Adam Slaker, @TamerineSky, @StillKnotKnown, @Test User, @youngmrz, @VDT-91, @kaigler, @Alexander Penzin, @Antti, @Alex, @Michael Ludlow, @Umaru
## 2.7.4 - Terminal & Workflow Enhancements
### ✨ New Features
+137 -3
View File
@@ -56,9 +56,10 @@ cd apps/backend && uv venv && uv pip install -r requirements.txt
# Frontend (from apps/frontend/)
cd apps/frontend && npm install
# Set up OAuth token
claude setup-token
# Add to apps/backend/.env: CLAUDE_CODE_OAUTH_TOKEN=your-token
# Authenticate (token auto-saved to Keychain)
claude
# Then type: /login
# Press Enter to open browser and complete OAuth
```
### Creating and Running Specs
@@ -401,6 +402,139 @@ const { t } = useTranslation(['errors']);
2. Use `namespace:section.key` format (e.g., `navigation:items.githubPRs`)
3. Never use hardcoded strings in JSX/TSX files
### Cross-Platform Development
**CRITICAL: This project supports Windows, macOS, and Linux. Platform-specific bugs are the #1 source of breakage.**
#### The Problem
When developers on macOS fix something using Mac-specific assumptions, it breaks on Windows. When Windows developers fix something, it breaks on macOS. This happens because:
1. **CI only tested on Linux** - Platform-specific bugs weren't caught until after merge
2. **Scattered platform checks** - `process.platform === 'win32'` checks were spread across 50+ files
3. **Hardcoded paths** - Direct paths like `C:\Program Files` or `/opt/homebrew/bin` throughout code
#### The Solution
**1. Centralized Platform Abstraction**
All platform-specific code now lives in dedicated modules:
- **Frontend:** `apps/frontend/src/main/platform/`
- **Backend:** `apps/backend/core/platform/`
**Import from these modules instead of checking `process.platform` directly:**
```typescript
// ❌ WRONG - Direct platform check
if (process.platform === 'win32') {
// Windows logic
}
// ✅ CORRECT - Use abstraction
import { isWindows, getPathDelimiter } from './platform';
if (isWindows()) {
// Windows logic
}
```
**2. Multi-Platform CI**
CI now tests on **all three platforms** (Windows, macOS, Linux). A PR cannot merge unless all platforms pass:
```yaml
# .github/workflows/ci.yml
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
```
**3. Platform Module API**
The platform module provides:
| Function | Purpose |
|----------|---------|
| `isWindows()` / `isMacOS()` / `isLinux()` | OS detection |
| `getPathDelimiter()` | Get `;` (Windows) or `:` (Unix) |
| `getExecutableExtension()` | Get `.exe` (Windows) or `` (Unix) |
| `findExecutable(name)` | Find executables across platforms |
| `getBinaryDirectories()` | Get platform-specific bin paths |
| `requiresShell(command)` | Check if .cmd/.bat needs shell on Windows |
**4. Path Handling Best Practices**
```typescript
// ❌ WRONG - Hardcoded Windows path
const claudePath = 'C:\\Program Files\\Claude\\claude.exe';
// ❌ WRONG - Hardcoded macOS path
const brewPath = '/opt/homebrew/bin/python3';
// ❌ WRONG - Manual path joining
const fullPath = dir + '/subdir/file.txt';
// ✅ CORRECT - Use platform abstraction
import { findExecutable, joinPaths } from './platform';
const claudePath = await findExecutable('claude');
const fullPath = joinPaths(dir, 'subdir', 'file.txt');
```
**5. Testing Platform-Specific Code**
```typescript
// Mock process.platform for testing
import { isWindows } from './platform';
// In tests, use jest.mock or similar
jest.mock('./platform', () => ({
isWindows: () => true // Simulate Windows
}));
```
**6. When You Need Platform-Specific Code**
If you must write platform-specific code:
1. **Add it to the platform module** - Not scattered in your feature code
2. **Write tests for all platforms** - Mock `process.platform` to test each case
3. **Use feature detection** - Check for file/path existence, not just OS name
4. **Document why** - Explain the platform difference in comments
**7. Submitting Platform-Specific Fixes**
When fixing a platform-specific bug:
1. Ensure your fix doesn't break other platforms
2. Test locally if you have access to other OSs
3. Rely on CI to catch issues you can't test
4. Consider adding a test that mocks other platforms
**Example: Adding a New Tool Detection**
```typescript
// ✅ CORRECT - Add to platform/paths.ts
export function getMyToolPaths(): string[] {
if (isWindows()) {
return [
joinPaths('C:', 'Program Files', 'MyTool', 'tool.exe'),
// ... more Windows paths
];
}
return [
joinPaths('/usr', 'local', 'bin', 'mytool'),
// ... more Unix paths
];
}
// ✅ CORRECT - Use in your code
import { findExecutable, getMyToolPaths } from './platform';
const toolPath = await findExecutable('mytool', getMyToolPaths());
```
### End-to-End Testing (Electron App)
**IMPORTANT: When bug fixing or implementing new features in the frontend, AI agents can perform automated E2E testing using the Electron MCP server.**
+126 -2
View File
@@ -16,6 +16,7 @@ Thank you for your interest in contributing to Auto Claude! This document provid
- [Testing](#testing)
- [Continuous Integration](#continuous-integration)
- [Git Workflow](#git-workflow)
- [Working with Forks](#working-with-forks)
- [Branch Overview](#branch-overview)
- [Main Branches](#main-branches)
- [Supporting Branches](#supporting-branches)
@@ -154,7 +155,7 @@ The project consists of two main components:
### Python Backend
The recommended way is to use `npm run install:backend`, but you can also set up manually:
The recommended way is to use `npm run install:backend` (or `npm run install:all` from the root), which automatically installs both runtime and test dependencies. You can also set up manually:
```bash
# Navigate to the backend directory
@@ -357,6 +358,64 @@ export default function(props) {
- End files with a newline
- Keep line length under 100 characters when practical
### File Encoding (Python)
**Always specify `encoding="utf-8"` for text file operations** to ensure Windows compatibility.
Windows Python defaults to `cp1252` encoding instead of UTF-8, causing errors with:
- Emoji (🚀, ✅, ❌)
- International characters (ñ, é, 中文, العربية)
- Special symbols (™, ©, ®)
**DO:**
```python
# Reading files
with open(path, encoding="utf-8") as f:
content = f.read()
# Writing files
with open(path, "w", encoding="utf-8") as f:
f.write(content)
# Path methods
from pathlib import Path
content = Path(file).read_text(encoding="utf-8")
Path(file).write_text(content, encoding="utf-8")
# JSON files - reading
import json
with open(path, encoding="utf-8") as f:
data = json.load(f)
# JSON files - writing
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
```
**DON'T:**
```python
# Wrong - platform-dependent encoding
with open(path) as f:
content = f.read()
# Wrong - Path methods without encoding
content = Path(file).read_text()
# Wrong - encoding on json.dump (not open!)
json.dump(data, f, encoding="utf-8") # ERROR
```
**Binary files - NO encoding:**
```python
with open(path, "rb") as f: # Correct
data = f.read()
```
Our pre-commit hooks automatically check for missing encoding parameters. See [PR #782](https://github.com/AndyMik90/Auto-Claude/pull/782) for the comprehensive encoding fix and [guides/windows-development.md](guides/windows-development.md) for Windows-specific development guidance.
## Testing
### Python Tests
@@ -429,7 +488,6 @@ All pull requests and pushes to `main` trigger automated CI checks via GitHub Ac
|----------|---------|----------------|
| **CI** | Push to `main`, PRs | Python tests (3.11 & 3.12), Frontend tests |
| **Lint** | Push to `main`, PRs | Ruff (Python), ESLint + TypeScript (Frontend) |
| **Test on Tag** | Version tags (`v*`) | Full test suite before release |
### PR Requirements
@@ -460,6 +518,72 @@ npm run typecheck
We use a **Git Flow** branching strategy to manage releases and parallel development.
### Working with Forks
When contributing to Auto Claude, you'll typically fork the repository first. Proper fork configuration is essential to avoid sync issues.
#### Initial Fork Setup
```bash
# 1. Fork on GitHub (click the Fork button on the repo page)
# 2. Clone YOUR fork (not the original repo)
git clone https://github.com/YOUR-USERNAME/Auto-Claude.git
cd Auto-Claude
# 3. Verify your remotes point to YOUR fork
git remote -v
# Should show:
# origin https://github.com/YOUR-USERNAME/Auto-Claude.git (fetch)
# origin https://github.com/YOUR-USERNAME/Auto-Claude.git (push)
# 4. Add upstream remote to sync with the original repo
git remote add upstream https://github.com/AndyMik90/Auto-Claude.git
```
#### Keeping Your Fork Updated
```bash
# Fetch latest changes from upstream
git fetch upstream
# Sync your develop branch with upstream
git checkout develop
git merge upstream/develop
git push origin develop
```
#### Converting a Fork to Standalone
> ⚠️ **Common Issue:** After making a fork standalone (e.g., disconnecting from the original repo on GitHub), your local git configuration may still reference the original forked repository, causing push/pull issues.
If you convert your fork to a standalone repository:
```bash
# 1. Update origin to point to your standalone repo
git remote set-url origin https://github.com/YOUR-USERNAME/Your-Standalone-Repo.git
# 2. Remove the upstream remote (no longer applicable)
git remote remove upstream
# 3. Verify your configuration
git remote -v
# Should only show your standalone repo as origin
# 4. Update your default branch tracking if needed
git branch --set-upstream-to=origin/main main
git branch --set-upstream-to=origin/develop develop
```
#### Troubleshooting Fork Issues
| Problem | Cause | Solution |
|---------|-------|----------|
| `Permission denied` on push | Origin points to upstream repo | `git remote set-url origin <your-fork-url>` |
| `Repository not found` | Fork was deleted or made standalone | Update remote URL to current repo location |
| Can't push to develop | Local branch tracks wrong remote | `git branch --set-upstream-to=origin/develop` |
| Commits show wrong author | Git config not set | `git config user.email "[email protected]"` |
### Branch Overview
```
-1
View File
@@ -186,7 +186,6 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
|----------|---------|---------|
| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag |
| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release |
| `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json |
| `update-readme` (in release.yml) | After release | Updates README with new version |
## Troubleshooting
+4 -4
View File
@@ -269,9 +269,9 @@ GRAPHITI_ENABLED=true
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# OpenRouter LLM Model (default: anthropic/claude-3.5-sonnet)
# Popular choices: anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
# OpenRouter LLM Model (default: anthropic/claude-sonnet-4)
# Popular choices: anthropic/claude-sonnet-4, openai/gpt-4o, google/gemini-2.0-flash
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
@@ -368,5 +368,5 @@ GRAPHITI_ENABLED=true
# GRAPHITI_LLM_PROVIDER=openrouter
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
+421
View File
@@ -0,0 +1,421 @@
# Token Encryption Investigation
## Issue Summary
Auto-Claude users are experiencing API 401 errors ("Invalid bearer token") because the Python backend is passing encrypted tokens (with `enc:` prefix) directly to the Claude Agent SDK without decryption. Standalone Claude Code terminals work correctly because they decrypt these tokens before use.
**Key insight from user thehaffk:** "python cant unencrypt claude token and it launches session with CLAUDE_CODE_OAUTH_TOKEN=enc:djEwtxMGISt3tQ..."
## Token Storage Format
### Encrypted Token Format
Claude Code CLI stores OAuth tokens in an encrypted format with the prefix `enc:`:
```text
enc:djEwtxMGISt3tQ...
```
This format is used when tokens are stored in:
- **macOS**: Keychain (service: "Claude Code-credentials")
- **Linux**: Secret Service API (DBus, via secretstorage library)
- **Windows**: Credential Manager / .credentials.json files
### Decrypted Token Format
Valid Claude OAuth tokens have the format:
```text
sk-ant-oat01-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
## Current Token Flow (BROKEN)
1. **Token Storage**: Claude Code CLI stores encrypted token with `enc:` prefix in system keychain
2. **Token Retrieval**: `apps/backend/core/auth.py::get_auth_token()` retrieves token from:
- Environment variable `CLAUDE_CODE_OAUTH_TOKEN`
- OR system keychain via `get_token_from_keychain()`
3. **❌ NO DECRYPTION**: Token is returned as-is with `enc:` prefix intact
4. **SDK Initialization**: Encrypted token passed to Claude Agent SDK
5. **API Call Fails**: SDK sends encrypted token to API → 401 error
### Proof of Broken Flow
Test in `apps/backend`:
```python
import os
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:test123'
from core.auth import get_auth_token
token = get_auth_token()
print(f"Token: {token}") # Output: "enc:test123"
print(f"Encrypted: {token.startswith('enc:')}") # Output: True
```
## How Standalone Claude Code CLI Handles Tokens
### Current Understanding
1. **Token Detection**: CLI checks if token starts with `enc:` prefix
2. **Decryption**: If encrypted, CLI decrypts using platform-specific keyring access
3. **Authentication**: Decrypted `sk-ant-oat01-` token is used for API calls
### Missing Documentation
Web search for "Claude Code CLI encrypted token enc: prefix decryption" found:
- Token storage formats (JSON with accessToken, refreshToken, expiresAt)
- Security issues (tokens exposed in debug logs before v2.1.0)
- Keychain access patterns for macOS/Linux/Windows
**❌ NOT FOUND**: Specific documentation on how Claude Code CLI decrypts `enc:` tokens
Sources:
- [Claude Code CLI over SSH on macOS: Fixing Keychain Access](https://phoenixtrap.com/2025/10/26/claude-code-cli-over-ssh-on-macos-fixing-keychain-access/)
- [Identity and Access Management - Claude Code Docs](https://code.claude.com/docs/en/iam)
- [Claude Code sessions should be encrypted | yoav.blog](https://yoav.blog/2026/01/09/claude-code-sessions-should-be-encrypted/)
## Decryption Approach Options
### Option 1: Claude Agent SDK Built-in Decryption
**Status**: NEEDS VERIFICATION
The Claude Agent SDK (`claude-agent-sdk>=0.1.19`) may handle decryption internally if:
- Token is passed to SDK still encrypted
- SDK detects `enc:` prefix
- SDK has access to system keyring for decryption
**Action Required**: Check if SDK has decryption capabilities by examining:
- SDK source code or documentation
- Whether SDK expects encrypted vs decrypted tokens
- If SDK requires specific environment variables for decryption
### Option 2: Python Backend Decryption (Recommended)
**Approach**: Implement decryption in `apps/backend/core/auth.py` before passing to SDK
**Implementation Pattern**:
```python
def get_auth_token() -> str | None:
"""Get authentication token (decrypted if necessary)."""
token = _retrieve_token_from_sources() # From env or keychain
if token and token.startswith("enc:"):
# Decrypt the token
token = decrypt_token(token)
return token
def decrypt_token(encrypted_token: str) -> str:
"""
Decrypt Claude Code encrypted token.
Args:
encrypted_token: Token with 'enc:' prefix
Returns:
Decrypted token in format 'sk-ant-oat01-...'
"""
# Remove 'enc:' prefix
encrypted_data = encrypted_token[4:]
# TODO: Implement decryption logic
# Questions to answer:
# 1. What encryption algorithm does Claude Code use?
# 2. Where is the decryption key stored?
# 3. Is the decryption key platform-specific (per-user)?
# 4. Can we reuse Claude Code's decryption mechanism?
raise NotImplementedError("Token decryption not yet implemented")
```
### Option 3: Call Claude Code CLI for Decryption
**Approach**: Use the Claude Code CLI binary to decrypt tokens
```python
def decrypt_token(encrypted_token: str) -> str:
"""Decrypt token by invoking Claude Code CLI."""
# Find claude binary
claude_path = shutil.which("claude") or "~/.local/bin/claude"
# Use CLI command to get decrypted token
# (if such a command exists - needs research)
result = subprocess.run(
[claude_path, "auth", "decrypt", encrypted_token],
capture_output=True,
text=True
)
return result.stdout.strip()
```
**Issues**:
- Requires Claude Code CLI to be installed
- No documented CLI command for token decryption
- Adds external dependency
## Required Investigation Steps
### 1. Verify SDK Decryption Capabilities
**Task**: Check if `claude-agent-sdk` handles `enc:` tokens automatically
**Method**:
```bash
# In environment with SDK installed
python3 << 'EOF'
import os
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:...' # Real encrypted token
from claude_agent_sdk import Client
# Try creating client - does it decrypt internally?
client = Client()
# Check if authentication works
EOF
```
### 2. Reverse Engineer Claude Code CLI Decryption
**Task**: Understand how Claude CLI decrypts tokens
**Method**:
- Examine Claude CLI binary (if possible)
- Trace system calls when CLI runs (strace on Linux, dtruss on macOS)
- Check if CLI accesses specific keychain entries for decryption keys
- Look for encryption/decryption libraries used by CLI
### 3. Find Decryption Key Storage
**Task**: Locate where decryption keys are stored
**Hypothesis**: Decryption key stored in:
- macOS: Keychain (separate entry from encrypted token)
- Linux: Secret Service API
- Windows: Credential Manager
**Verification**:
```bash
# macOS: List all keychain entries
security find-generic-password -a "$(whoami)" | grep -i claude
# Linux: Use secretstorage to list all items
python3 -c "import secretstorage; ..."
```
## Recommended Decryption Approach for Python Backend
Based on investigation so far, the recommended approach is:
1. **Detect encrypted tokens**: Check for `enc:` prefix in `get_auth_token()`
2. **Decrypt before use**: Implement `decrypt_token()` function
3. **Platform-specific decryption**: Use appropriate keyring library:
- macOS: Use `subprocess` with `/usr/bin/security` to access decryption key
- Linux: Use `secretstorage` library to access Secret Service API
- Windows: Access Credential Manager or credentials.json
4. **Backward compatibility**: Support both encrypted and plaintext tokens
5. **Error handling**: Provide clear error messages if decryption fails
## Complete Token Flow Trace (Frontend → Backend)
### 1. Token Retrieval (Frontend)
**File**: `apps/frontend/src/main/services/profile-service.ts`
The frontend retrieves the OAuth token from the system keychain but **does not decrypt it**. When no API profile is active (OAuth mode), the frontend returns an empty environment object, which means it relies on:
- The token already being in the environment as `CLAUDE_CODE_OAUTH_TOKEN`
- OR the Python backend retrieving it from the keychain
**Key Code**:
```typescript
// Line 223: Returns empty object in OAuth mode, allowing
// CLAUDE_CODE_OAUTH_TOKEN to be used from system keychain
```
### 2. Environment Variable Passing (Frontend → PTY)
**File**: `apps/frontend/src/main/terminal/pty-manager.ts`
The PTY manager spawns the terminal shell with environment variables, including `CLAUDE_CODE_OAUTH_TOKEN`:
**Key Code** (Lines 149-152):
```typescript
// Remove ANTHROPIC_API_KEY to ensure Claude Code uses OAuth tokens
// (CLAUDE_CODE_OAUTH_TOKEN from profileEnv) instead of API keys
const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env;
```
**Important**: The frontend passes through whatever token value exists in the environment - it does NOT check for `enc:` prefix or decrypt it.
### 3. Token Retrieval (Backend)
**File**: `apps/backend/core/auth.py`
#### 3.1. get_auth_token()
This function retrieves the token from multiple sources:
```python
def get_auth_token() -> str | None:
# First check environment variables
for var in AUTH_TOKEN_ENV_VARS: # CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN
token = os.environ.get(var)
if token:
return token # ❌ Returns immediately without checking for enc: prefix
# Fallback to system credential store
return get_token_from_keychain() # ❌ Also returns without decryption
```
**Issue**: Returns token as-is with `enc:` prefix intact.
#### 3.2. require_auth_token()
This function calls `get_auth_token()` and raises an error if no token is found:
```python
def require_auth_token() -> str:
token = get_auth_token() # ❌ Gets encrypted token
if not token:
raise ValueError("No OAuth token found...")
return token # ❌ Returns encrypted token
```
**Issue**: No decryption step between retrieval and return.
#### 3.3. ensure_claude_code_oauth_token()
This function ensures the environment variable is set:
```python
def ensure_claude_code_oauth_token() -> None:
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
return
token = get_auth_token() # ❌ Gets encrypted token
if token:
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token # ❌ Sets encrypted token
```
**Issue**: Propagates encrypted token to environment variable.
### 4. Token Usage in SDK Client Creation (Backend)
#### 4.1. Full Client Creation
**File**: `apps/backend/core/client.py` (see `create_client()` function)
```python
def create_client(...):
oauth_token = require_auth_token() # ❌ Gets encrypted token
# Ensure SDK can access it via its expected env var
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
```
**Issue**: Encrypted token is passed to the Claude Agent SDK, which expects a decrypted `sk-ant-oat01-` token.
#### 4.2. Simple Client Creation
**File**: `apps/backend/core/simple_client.py` (see `create_simple_client()` function)
```python
def create_simple_client(...):
# Get authentication
oauth_token = require_auth_token() # ❌ Gets encrypted token
import os
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
```
**Issue**: Same problem - encrypted token passed to SDK.
#### 4.3. Other Usages
**Files**:
- `apps/backend/core/workspace.py` (Line 1966) - AI merge operations
- `apps/backend/runners/insights_runner.py` - Insights analysis
- `apps/backend/runners/github/batch_issues.py` - GitHub batch operations
- `apps/backend/integrations/linear/updater.py` - Linear integration
- `apps/backend/commit_message.py` - Commit message generation
- `apps/backend/analysis/insight_extractor.py` - Code insights
- `apps/backend/merge/ai_resolver/claude_client.py` - Merge resolution
**All follow the same pattern**: Call `ensure_claude_code_oauth_token()` → encrypted token in environment → SDK receives encrypted token → API 401 error.
### 5. Where Decryption Should Be Inserted
Based on the flow analysis, decryption should be added at **the earliest point of token retrieval** to avoid duplicating decryption logic:
**RECOMMENDED INSERTION POINT**: `apps/backend/core/auth.py::get_auth_token()`
```python
def get_auth_token() -> str | None:
# First check environment variables
for var in AUTH_TOKEN_ENV_VARS:
token = os.environ.get(var)
if token:
# ✅ INSERT DECRYPTION HERE
if token.startswith("enc:"):
token = decrypt_token(token)
return token
# Fallback to system credential store
token = get_token_from_keychain()
# ✅ ALSO DECRYPT KEYCHAIN TOKENS
if token and token.startswith("enc:"):
token = decrypt_token(token)
return token
```
**Benefits of this approach**:
1. Single location for decryption logic
2. All downstream functions automatically get decrypted tokens
3. Backward compatible (plaintext tokens pass through unchanged)
4. Consistent behavior across all token sources (env vars and keychain)
**Alternative insertion points** (NOT recommended):
- `require_auth_token()` - Would need similar logic in `get_auth_token()` for non-required usage
- `create_client()` - Would need duplication in `create_simple_client()` and all other clients
- `ensure_claude_code_oauth_token()` - Would miss direct `get_auth_token()` calls
## Next Steps
1. ✅ Document current token flow and identify issue (THIS FILE - COMPLETED)
2. ✅ Trace token flow from frontend to backend (THIS FILE - COMPLETED)
3. ✅ Identify where decryption should be inserted (THIS FILE - COMPLETED)
4. ⏳ Verify if Claude Agent SDK handles decryption internally
5. ⏳ Reverse engineer or document Claude Code CLI decryption mechanism
6. ⏳ Implement `decrypt_token()` function in `apps/backend/core/auth.py`
7. ⏳ Add encryption detection and auto-decryption to `get_auth_token()`
8. ⏳ Test with real encrypted tokens on macOS and Linux
9. ⏳ Add comprehensive error handling for decryption failures
## Open Questions
1. **What encryption algorithm does Claude Code use for `enc:` tokens?**
- Possible: AES-256, ChaCha20, or similar
- Key derivation method?
2. **Where is the decryption key stored?**
- Same keychain entry as encrypted token?
- Separate keychain entry?
- Derived from system/user credentials?
3. **Does Claude Agent SDK expect encrypted or decrypted tokens?**
- If it expects decrypted: we must decrypt before passing
- If it handles encryption: we may be missing SDK configuration
4. **Is there a Claude Code CLI command to decrypt tokens?**
- `claude auth decrypt <token>`?
- `claude auth get-token`?
- No documented command found in research
5. **Can we reuse Claude Code's decryption mechanism?**
- Import decryption functions from CLI?
- Call CLI as subprocess?
- Implement decryption ourselves?
## References
- Issue: [GitHub #1223: API Error 401](https://github.com/AndyMik90/Auto-Claude/issues/1223)
- Current auth implementation: `apps/backend/core/auth.py`
- SDK client initialization: `apps/backend/core/client.py`
- Requirements: `apps/backend/requirements.txt` (includes `secretstorage>=3.3.3` for Linux)
+6 -4
View File
@@ -17,12 +17,14 @@ python -m pip install -r requirements.txt
cp .env.example .env
```
Set your Claude API token in `.env`:
```
CLAUDE_CODE_OAUTH_TOKEN=your-token-here
Authenticate with Claude Code (token auto-saved to Keychain):
```bash
claude
# Type: /login
# Press Enter to open browser
```
Get your token by running: `claude setup-token`
Token is auto-detected from macOS Keychain / Windows Credential Manager.
### 3. Run
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.4"
__version__ = "2.7.5"
__author__ = "Auto Claude Team"
+32 -3
View File
@@ -226,7 +226,7 @@ async def run_autonomous_agent(
print(" PAUSED BY HUMAN")
print("=" * 70)
pause_content = pause_file.read_text().strip()
pause_content = pause_file.read_text(encoding="utf-8").strip()
if pause_content:
print(f"\nMessage: {pause_content}")
@@ -319,7 +319,9 @@ async def run_autonomous_agent(
task_logger.set_session(iteration)
else:
# Switch to coding phase after planning
just_transitioned_from_planning = False
if is_planning_phase:
just_transitioned_from_planning = True
is_planning_phase = False
current_log_phase = LogPhase.CODING
emit_phase(ExecutionPhase.CODING, "Starting implementation")
@@ -338,8 +340,35 @@ async def run_autonomous_agent(
print_status("Phase transition synced to main project", "success")
if not next_subtask:
print("No pending subtasks found - build may be complete!")
break
# FIX for Issue #495: Race condition after planning phase
# The implementation_plan.json may not be fully flushed to disk yet,
# or there may be a brief delay before subtasks become available.
# Retry with exponential backoff before giving up.
if just_transitioned_from_planning:
print_status(
"Waiting for implementation plan to be ready...", "progress"
)
for retry_attempt in range(3):
delay = (retry_attempt + 1) * 2 # 2s, 4s, 6s
await asyncio.sleep(delay)
next_subtask = get_next_subtask(spec_dir)
if next_subtask:
# Update subtask_id and phase_name after successful retry
subtask_id = next_subtask.get("id")
phase_name = next_subtask.get("phase_name")
print_status(
f"Found subtask {subtask_id} after {delay}s delay",
"success",
)
break
print_status(
f"Retry {retry_attempt + 1}/3: No subtask found yet...",
"warning",
)
if not next_subtask:
print("No pending subtasks found - build may be complete!")
break
# Get attempt count for recovery context
attempt_count = recovery_manager.get_attempt_count(subtask_id)
+3 -1
View File
@@ -247,7 +247,9 @@ AGENT_CONFIGS = {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
# Note: Default to "none" because insight_extractor uses Haiku which doesn't support thinking
# If using Sonnet/Opus models, override max_thinking_tokens in create_simple_client()
"thinking_default": "none",
},
"merge_resolver": {
"tools": [], # Text-only analysis
@@ -171,7 +171,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
# PRIMARY: Save to file-based storage (always works)
# Load existing map or create new
if codebase_map_file.exists():
with open(codebase_map_file) as f:
with open(codebase_map_file, encoding="utf-8") as f:
codebase_map = json.load(f)
else:
codebase_map = {
@@ -187,7 +187,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
}
codebase_map["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(codebase_map_file, "w") as f:
with open(codebase_map_file, "w", encoding="utf-8") as f:
json.dump(codebase_map, f, indent=2)
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
@@ -246,7 +246,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
entry += f"\n\n_Context: {context}_"
entry += "\n"
with open(gotchas_file, "a") as f:
with open(gotchas_file, "a", encoding="utf-8") as f:
if not gotchas_file.exists() or gotchas_file.stat().st_size == 0:
f.write(
"# Gotchas & Pitfalls\n\nThings to watch out for in this codebase.\n"
@@ -303,7 +303,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
codebase_map_file = memory_dir / "codebase_map.json"
if codebase_map_file.exists():
try:
with open(codebase_map_file) as f:
with open(codebase_map_file, encoding="utf-8") as f:
codebase_map = json.load(f)
discoveries = codebase_map.get("discovered_files", {})
@@ -319,7 +319,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
gotchas_file = memory_dir / "gotchas.md"
if gotchas_file.exists():
try:
content = gotchas_file.read_text()
content = gotchas_file.read_text(encoding="utf-8")
if content.strip():
result_parts.append("\n## Gotchas")
# Take last 1000 chars to avoid too much context
@@ -333,7 +333,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
patterns_file = memory_dir / "patterns.md"
if patterns_file.exists():
try:
content = patterns_file.read_text()
content = patterns_file.read_text(encoding="utf-8")
if content.strip():
result_parts.append("\n## Patterns")
result_parts.append(
@@ -57,7 +57,7 @@ def create_progress_tools(spec_dir: Path, project_dir: Path) -> list:
}
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
stats = {
+96 -28
View File
@@ -6,10 +6,14 @@ Tools for managing QA status and sign-off in implementation_plan.json.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from core.file_utils import write_json_atomic
from spec.validate_pkg.auto_fix import auto_fix_plan
try:
from claude_agent_sdk import tool
@@ -19,6 +23,53 @@ except ImportError:
tool = None
def _apply_qa_update(
plan: dict[str, Any],
status: str,
issues: list[Any],
tests_passed: dict[str, Any],
) -> int:
"""
Apply QA update to the plan and return the new QA session number.
Args:
plan: The implementation plan dict
status: QA status (pending, in_review, approved, rejected, fixes_applied)
issues: List of issues found
tests_passed: Dict of test results
Returns:
The new QA session number
"""
# Get current QA session number
current_qa = plan.get("qa_signoff", {})
qa_session = current_qa.get("qa_session", 0)
if status in ["in_review", "rejected"]:
qa_session += 1
plan["qa_signoff"] = {
"status": status,
"qa_session": qa_session,
"issues_found": issues,
"tests_passed": tests_passed,
"timestamp": datetime.now(timezone.utc).isoformat(),
"ready_for_qa_revalidation": status == "fixes_applied",
}
# Update plan status to match QA result
# This ensures the UI shows the correct column after QA
if status == "approved":
plan["status"] = "human_review"
plan["planStatus"] = "review"
elif status == "rejected":
plan["status"] = "human_review"
plan["planStatus"] = "review"
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
return qa_session
def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create QA management tools.
@@ -89,37 +140,13 @@ def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
except json.JSONDecodeError:
tests_passed = {}
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
# Get current QA session number
current_qa = plan.get("qa_signoff", {})
qa_session = current_qa.get("qa_session", 0)
if status in ["in_review", "rejected"]:
qa_session += 1
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
plan["qa_signoff"] = {
"status": status,
"qa_session": qa_session,
"issues_found": issues,
"tests_passed": tests_passed,
"timestamp": datetime.now(timezone.utc).isoformat(),
"ready_for_qa_revalidation": status == "fixes_applied",
}
# Update plan status to match QA result
# This ensures the UI shows the correct column after QA
if status == "approved":
plan["status"] = "human_review"
plan["planStatus"] = "review"
elif status == "rejected":
plan["status"] = "human_review"
plan["planStatus"] = "review"
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(plan_file, "w") as f:
json.dump(plan, f, indent=2)
# Use atomic write to prevent file corruption
write_json_atomic(plan_file, plan, indent=2)
return {
"content": [
@@ -130,6 +157,47 @@ def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
]
}
except json.JSONDecodeError as e:
# Attempt to auto-fix the plan and retry
if auto_fix_plan(spec_dir):
# Retry after fix
try:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
write_json_atomic(plan_file, plan, indent=2)
return {
"content": [
{
"type": "text",
"text": f"Updated QA status to '{status}' (session {qa_session}) (after auto-fix)",
}
]
}
except Exception as retry_err:
logging.warning(
f"QA update retry failed after auto-fix: {retry_err} (original error: {e})"
)
return {
"content": [
{
"type": "text",
"text": f"Error: QA update failed after auto-fix: {retry_err} (original JSON error: {e})",
}
]
}
return {
"content": [
{
"type": "text",
"text": f"Error: Invalid JSON in implementation_plan.json: {e}",
}
]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error updating QA status: {e}"}]
+88 -19
View File
@@ -6,10 +6,14 @@ Tools for managing subtask status in implementation_plan.json.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from core.file_utils import write_json_atomic
from spec.validate_pkg.auto_fix import auto_fix_plan
try:
from claude_agent_sdk import tool
@@ -19,6 +23,43 @@ except ImportError:
tool = None
def _update_subtask_in_plan(
plan: dict[str, Any],
subtask_id: str,
status: str,
notes: str,
) -> bool:
"""
Update a subtask in the plan.
Args:
plan: The implementation plan dict
subtask_id: ID of the subtask to update
status: New status (pending, in_progress, completed, failed)
notes: Optional notes to add
Returns:
True if subtask was found and updated, False otherwise
"""
subtask_found = False
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
subtask["status"] = status
if notes:
subtask["notes"] = notes
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
subtask_found = True
break
if subtask_found:
break
if subtask_found:
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
return subtask_found
def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create subtask management tools.
@@ -72,22 +113,10 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
}
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
# Find and update the subtask
subtask_found = False
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
subtask["status"] = status
if notes:
subtask["notes"] = notes
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
subtask_found = True
break
if subtask_found:
break
subtask_found = _update_subtask_in_plan(plan, subtask_id, status, notes)
if not subtask_found:
return {
@@ -99,11 +128,8 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
]
}
# Update plan metadata
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(plan_file, "w") as f:
json.dump(plan, f, indent=2)
# Use atomic write to prevent file corruption
write_json_atomic(plan_file, plan, indent=2)
return {
"content": [
@@ -115,6 +141,49 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
}
except json.JSONDecodeError as e:
# Attempt to auto-fix the plan and retry
if auto_fix_plan(spec_dir):
# Retry after fix
try:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
subtask_found = _update_subtask_in_plan(
plan, subtask_id, status, notes
)
if subtask_found:
write_json_atomic(plan_file, plan, indent=2)
return {
"content": [
{
"type": "text",
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}' (after auto-fix)",
}
]
}
else:
return {
"content": [
{
"type": "text",
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan (after auto-fix)",
}
]
}
except Exception as retry_err:
logging.warning(
f"Subtask update retry failed after auto-fix: {retry_err}"
)
return {
"content": [
{
"type": "text",
"text": f"Error: Subtask update failed after auto-fix: {retry_err}",
}
]
}
return {
"content": [
{
+2 -2
View File
@@ -48,9 +48,9 @@ def load_implementation_plan(spec_dir: Path) -> dict | None:
if not plan_file.exists():
return None
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
+2 -2
View File
@@ -46,7 +46,7 @@ def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict:
if output_file:
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w") as f:
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
print(f"Project index saved to: {output_file}")
@@ -87,7 +87,7 @@ def analyze_service(
if output_file:
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w") as f:
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
print(f"Service analysis saved to: {output_file}")
+1 -1
View File
@@ -99,7 +99,7 @@ class BaseAnalyzer:
def _read_file(self, path: str) -> str:
"""Read a file relative to the analyzer's path."""
try:
return (self.path / path).read_text()
return (self.path / path).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return ""
@@ -126,7 +126,7 @@ class AuthDetector(BaseAnalyzer):
for py_file in all_py_files:
try:
content = py_file.read_text()
content = py_file.read_text(encoding="utf-8")
# Find custom decorators
if (
"@require" in content
@@ -52,7 +52,7 @@ class JobsDetector(BaseAnalyzer):
tasks = []
for task_file in celery_files:
try:
content = task_file.read_text()
content = task_file.read_text(encoding="utf-8")
# Find @celery.task or @shared_task decorators
task_pattern = r"@(?:celery\.task|shared_task|app\.task)\s*(?:\([^)]*\))?\s*def\s+(\w+)"
task_matches = re.findall(task_pattern, content)
@@ -77,7 +77,7 @@ class MonitoringDetector(BaseAnalyzer):
continue
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
# Look for actual Prometheus imports or usage patterns
prometheus_patterns = [
"from prometheus_client import",
@@ -52,7 +52,7 @@ class DatabaseDetector(BaseAnalyzer):
for file_path in py_files:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -119,7 +119,7 @@ class DatabaseDetector(BaseAnalyzer):
for file_path in model_files:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -168,7 +168,7 @@ class DatabaseDetector(BaseAnalyzer):
return models
try:
content = schema_file.read_text()
content = schema_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return models
@@ -216,7 +216,7 @@ class DatabaseDetector(BaseAnalyzer):
for file_path in ts_files:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -265,7 +265,7 @@ class DatabaseDetector(BaseAnalyzer):
for file_path in schema_files:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -295,7 +295,7 @@ class DatabaseDetector(BaseAnalyzer):
for file_path in model_files:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -287,6 +287,6 @@ class ProjectAnalyzer:
def _read_file(self, path: str) -> str:
try:
return (self.project_dir / path).read_text()
return (self.project_dir / path).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return ""
@@ -66,7 +66,7 @@ class RouteDetector(BaseAnalyzer):
for file_path in files_to_check:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -130,7 +130,7 @@ class RouteDetector(BaseAnalyzer):
for file_path in files_to_check:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -179,7 +179,7 @@ class RouteDetector(BaseAnalyzer):
for file_path in url_files:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -218,7 +218,7 @@ class RouteDetector(BaseAnalyzer):
files_to_check = js_files + ts_files
for file_path in files_to_check:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -283,7 +283,7 @@ class RouteDetector(BaseAnalyzer):
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
try:
content = route_file.read_text()
content = route_file.read_text(encoding="utf-8")
# Detect exported methods: export async function GET(request)
methods = re.findall(
r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)",
@@ -348,7 +348,7 @@ class RouteDetector(BaseAnalyzer):
for file_path in go_files:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
@@ -384,7 +384,7 @@ class RouteDetector(BaseAnalyzer):
for file_path in rust_files:
try:
content = file_path.read_text()
content = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
+4 -4
View File
@@ -163,7 +163,7 @@ class CIDiscovery:
)
try:
content = wf_file.read_text()
content = wf_file.read_text(encoding="utf-8")
workflow_data = self._parse_yaml(content)
if not workflow_data:
@@ -242,7 +242,7 @@ class CIDiscovery:
)
try:
content = config_file.read_text()
content = config_file.read_text(encoding="utf-8")
data = self._parse_yaml(content)
if not data:
@@ -312,7 +312,7 @@ class CIDiscovery:
)
try:
content = config_file.read_text()
content = config_file.read_text(encoding="utf-8")
data = self._parse_yaml(content)
if not data:
@@ -370,7 +370,7 @@ class CIDiscovery:
)
try:
content = jenkinsfile.read_text()
content = jenkinsfile.read_text(encoding="utf-8")
# Extract sh commands using regex
sh_pattern = re.compile(r'sh\s+[\'"]([^\'"]+)[\'"]')
+5 -3
View File
@@ -33,7 +33,9 @@ except ImportError:
from core.auth import ensure_claude_code_oauth_token, get_auth_token
# Default model for insight extraction (fast and cheap)
DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest"
# Note: Using Haiku 4.5 for fast, cheap extraction. Haiku does not support
# extended thinking, so thinking_default is set to "none" in models.py
DEFAULT_EXTRACTION_MODEL = "claude-haiku-4-5-20251001"
# Maximum diff size to send to the LLM (avoid context limits)
MAX_DIFF_CHARS = 15000
@@ -235,7 +237,7 @@ def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str:
return f"Subtask: {subtask_id}"
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
# Search through phases for the subtask
@@ -278,7 +280,7 @@ def _build_extraction_prompt(inputs: dict) -> str:
prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md"
if prompt_file.exists():
base_prompt = prompt_file.read_text()
base_prompt = prompt_file.read_text(encoding="utf-8")
else:
# Fallback if prompt file missing
base_prompt = """Extract structured insights from this coding session.
+4 -4
View File
@@ -302,7 +302,7 @@ class TestDiscovery:
try:
with open(package_json, encoding="utf-8") as f:
pkg = json.load(f)
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return
deps = pkg.get("dependencies", {})
@@ -398,7 +398,7 @@ class TestDiscovery:
# Check pyproject.toml
pyproject = project_dir / "pyproject.toml"
if pyproject.exists():
content = pyproject.read_text()
content = pyproject.read_text(encoding="utf-8")
# Check for pytest
if "pytest" in content:
@@ -418,7 +418,7 @@ class TestDiscovery:
# Check requirements.txt
requirements = project_dir / "requirements.txt"
if requirements.exists():
content = requirements.read_text().lower()
content = requirements.read_text(encoding="utf-8").lower()
if "pytest" in content and not any(
f.name == "pytest" for f in result.frameworks
):
@@ -496,7 +496,7 @@ class TestDiscovery:
if not gemfile.exists():
return
content = gemfile.read_text().lower()
content = gemfile.read_text(encoding="utf-8").lower()
if "rspec" in content or (project_dir / ".rspec").exists():
result.frameworks.append(
+3 -3
View File
@@ -31,7 +31,7 @@ def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
return False
try:
with open(batch_path) as f:
with open(batch_path, encoding="utf-8") as f:
batch_data = json.load(f)
except json.JSONDecodeError as e:
print_status(f"Invalid JSON in batch file: {e}", "error")
@@ -81,7 +81,7 @@ def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
}
req_file = spec_dir / "requirements.json"
with open(req_file, "w") as f:
with open(req_file, "w", encoding="utf-8") as f:
json.dump(requirements, f, indent=2, default=str)
created_specs.append(
@@ -145,7 +145,7 @@ def handle_batch_status_command(project_dir: str) -> bool:
if req_file.exists():
try:
with open(req_file) as f:
with open(req_file, encoding="utf-8") as f:
req = json.load(f)
title = req.get("task_description", title)
except json.JSONDecodeError:
+1 -1
View File
@@ -421,7 +421,7 @@ def _handle_build_interrupt(
if human_input:
# Save to HUMAN_INPUT.md
input_file = spec_dir / "HUMAN_INPUT.md"
input_file.write_text(human_input)
input_file.write_text(human_input, encoding="utf-8")
content = [
success(f"{icon(Icons.SUCCESS)} INSTRUCTIONS SAVED"),
+3 -3
View File
@@ -121,7 +121,7 @@ def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
# Expand ~ and resolve path
file_path = Path(file_path_str).expanduser().resolve()
if file_path.exists():
followup_task = file_path.read_text().strip()
followup_task = file_path.read_text(encoding="utf-8").strip()
if followup_task:
print_status(
f"Loaded {len(followup_task)} characters from file",
@@ -195,7 +195,7 @@ def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
# Save to FOLLOWUP_REQUEST.md
request_file = spec_dir / "FOLLOWUP_REQUEST.md"
request_file.write_text(followup_task)
request_file.write_text(followup_task, encoding="utf-8")
# Show confirmation
content = [
@@ -285,7 +285,7 @@ def handle_followup_command(
# Check for prior follow-ups (for sequential follow-up context)
prior_followup_count = 0
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan_data = json.load(f)
phases = plan_data.get("phases", [])
# Count phases that look like follow-up phases (name contains "Follow" or high phase number)
+1 -1
View File
@@ -149,7 +149,7 @@ def read_from_file() -> str | None:
# Expand ~ and resolve path
file_path = Path(file_path_input).expanduser().resolve()
if file_path.exists():
content = file_path.read_text().strip()
content = file_path.read_text(encoding="utf-8").strip()
if content:
print_status(
f"Loaded {len(content)} characters from file",
+4 -4
View File
@@ -74,12 +74,12 @@ Examples:
python auto-claude/run.py --spec 001 --qa-status # Check QA validation status
Prerequisites:
1. Create a spec first: claude /spec
2. Run 'claude setup-token' and set CLAUDE_CODE_OAUTH_TOKEN
1. Authenticate: Run 'claude' and type '/login'
2. Create a spec first: claude /spec
Environment Variables:
CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (required)
Get it by running: claude setup-token
CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (auto-detected from Keychain)
Or authenticate via: claude → /login
AUTO_BUILD_MODEL Override default model (optional)
""",
)
+6 -1
View File
@@ -56,7 +56,9 @@ def import_dotenv():
# Load .env with helpful error if dependencies not installed
load_dotenv = import_dotenv()
from graphiti_config import get_graphiti_status
# NOTE: graphiti_config is imported lazily in validate_environment() to avoid
# triggering graphiti_core -> real_ladybug -> pywintypes import chain before
# platform dependency validation can run. See ACS-253.
from linear_integration import LinearManager
from linear_updater import is_linear_enabled
from spec.pipeline import get_specs_dir
@@ -205,6 +207,9 @@ def validate_environment(spec_dir: Path) -> bool:
print("Linear integration: DISABLED (set LINEAR_API_KEY to enable)")
# Check Graphiti integration (optional but show status)
# Lazy import to avoid triggering pywintypes import before validation (ACS-253)
from graphiti_config import get_graphiti_status
graphiti_status = get_graphiti_status()
if graphiti_status["available"]:
print("Graphiti memory: ENABLED")
+1 -1
View File
@@ -182,7 +182,7 @@ def _detect_worktree_base_branch(
config_path = worktree_path / ".auto-claude" / "worktree-config.json"
if config_path.exists():
try:
config = json.loads(config_path.read_text())
config = json.loads(config_path.read_text(encoding="utf-8"))
if config.get("base_branch"):
debug(
MODULE,
+9 -3
View File
@@ -37,8 +37,12 @@ class ContextBuilder:
"""Load project index from file or create new one (.auto-claude is the installed instance)."""
index_file = self.project_dir / ".auto-claude" / "project_index.json"
if index_file.exists():
with open(index_file) as f:
return json.load(f)
try:
with open(index_file, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
# Corrupted or legacy-encoded file, regenerate
pass
# Try to create one
from analyzer import analyze_project
@@ -230,7 +234,9 @@ class ContextBuilder:
if context_file.exists():
return {
"source": "SERVICE_CONTEXT.md",
"content": context_file.read_text()[:2000], # First 2000 chars
"content": context_file.read_text(encoding="utf-8")[
:2000
], # First 2000 chars
}
# Generate basic context from service info
+1 -1
View File
@@ -72,7 +72,7 @@ def build_task_context(
if output_file:
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w") as f:
with open(output_file, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2)
print(f"Task context saved to: {output_file}")
+1 -1
View File
@@ -38,7 +38,7 @@ class PatternDiscoverer:
for match in reference_files[:max_files]:
try:
file_path = self.project_dir / match.path
content = file_path.read_text(errors="ignore")
content = file_path.read_text(encoding="utf-8", errors="ignore")
# Look for common patterns
for keyword in keywords:
+1 -1
View File
@@ -41,7 +41,7 @@ class CodeSearcher:
for file_path in self._iter_code_files(service_path):
try:
content = file_path.read_text(errors="ignore")
content = file_path.read_text(encoding="utf-8", errors="ignore")
content_lower = content.lower()
# Score this file
+2 -2
View File
@@ -41,7 +41,7 @@ def save_context(context: TaskContext, output_file: Path) -> None:
output_file: Path to output JSON file
"""
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w") as f:
with open(output_file, "w", encoding="utf-8") as f:
json.dump(serialize_context(context), f, indent=2)
@@ -55,5 +55,5 @@ def load_context(input_file: Path) -> dict:
Returns:
Context dictionary
"""
with open(input_file) as f:
with open(input_file, encoding="utf-8") as f:
return json.load(f)
+697 -43
View File
@@ -7,9 +7,29 @@ for custom API endpoints.
"""
import json
import logging
import os
import platform
import shutil
import subprocess
from typing import TYPE_CHECKING
from core.platform import (
is_linux,
is_macos,
is_windows,
)
logger = logging.getLogger(__name__)
# Optional import for Linux secret-service support
# secretstorage provides access to the Freedesktop.org Secret Service API via DBus
if TYPE_CHECKING:
import secretstorage
else:
try:
import secretstorage # type: ignore[import-untyped]
except ImportError:
secretstorage = None # type: ignore[assignment]
# Priority order for auth token resolution
# NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY.
@@ -38,9 +58,294 @@ SDK_ENV_VARS = [
"API_TIMEOUT_MS",
# Windows-specific: Git Bash path for Claude Code CLI
"CLAUDE_CODE_GIT_BASH_PATH",
# Claude CLI path override (allows frontend to pass detected CLI path to SDK)
"CLAUDE_CLI_PATH",
# Profile's custom config directory (for multi-profile token storage)
"CLAUDE_CONFIG_DIR",
]
def is_encrypted_token(token: str | None) -> bool:
"""
Check if a token is encrypted (has "enc:" prefix).
Args:
token: Token string to check (can be None)
Returns:
True if token starts with "enc:", False otherwise
"""
return bool(token and token.startswith("enc:"))
def validate_token_not_encrypted(token: str) -> None:
"""
Validate that a token is not in encrypted format.
This function should be called before passing a token to the Claude Agent SDK
to ensure proper error messages when decryption has failed.
Args:
token: Token string to validate
Raises:
ValueError: If token is in encrypted format (enc:...)
"""
if is_encrypted_token(token):
raise ValueError(
"Authentication token is in encrypted format and cannot be used.\n\n"
"The token decryption process failed or was not attempted.\n\n"
"To fix this issue:\n"
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file\n\n"
"Note: Encrypted tokens require the Claude Code CLI to be installed\n"
"and properly configured with system keychain access."
)
def decrypt_token(encrypted_token: str) -> str:
"""
Decrypt Claude Code encrypted token.
NOTE: This implementation currently relies on the system keychain (macOS Keychain,
Linux Secret Service, Windows Credential Manager) to provide already-decrypted tokens.
Encrypted tokens in the CLAUDE_CODE_OAUTH_TOKEN environment variable are NOT supported
and will fail with NotImplementedError.
For encrypted token support, users should:
1. Run: claude setup-token (stores decrypted token in system keychain)
2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file
Claude Code CLI stores OAuth tokens in encrypted format with "enc:" prefix.
This function attempts to decrypt the token using platform-specific methods.
Cross-platform token decryption approaches:
- macOS: Token stored in Keychain with encryption key
- Linux: Token stored in Secret Service API with encryption key
- Windows: Token stored in Credential Manager or .credentials.json
Args:
encrypted_token: Token with 'enc:' prefix from Claude Code CLI
Returns:
Decrypted token in format 'sk-ant-oat01-...'
Raises:
ValueError: If token format is invalid or decryption fails
"""
# Validate encrypted token format
if not isinstance(encrypted_token, str):
raise ValueError(
f"Invalid token type. Expected string, got: {type(encrypted_token).__name__}"
)
if not encrypted_token.startswith("enc:"):
raise ValueError(
"Invalid encrypted token format. Token must start with 'enc:' prefix."
)
# Remove 'enc:' prefix to get encrypted data
encrypted_data = encrypted_token[4:]
if not encrypted_data:
raise ValueError("Empty encrypted token data after 'enc:' prefix")
# Basic validation of encrypted data format
# Encrypted data should be a reasonable length (at least 10 chars)
if len(encrypted_data) < 10:
raise ValueError(
"Encrypted token data is too short. The token may be corrupted."
)
# Check for obviously invalid characters that suggest corruption
# Accepts both standard base64 (+/) and URL-safe base64 (-_) to be permissive
if not all(c.isalnum() or c in "+-_/=" for c in encrypted_data):
raise ValueError(
"Encrypted token contains invalid characters. "
"Expected base64-encoded data. The token may be corrupted."
)
# Attempt platform-specific decryption
try:
if is_macos():
return _decrypt_token_macos(encrypted_data)
elif is_linux():
return _decrypt_token_linux(encrypted_data)
elif is_windows():
return _decrypt_token_windows(encrypted_data)
else:
raise ValueError("Unsupported platform for token decryption")
except NotImplementedError as e:
# Decryption not implemented - log warning and provide guidance
logger.warning(
"Token decryption failed: %s. Users must use plaintext tokens.", str(e)
)
raise ValueError(
f"Encrypted token decryption is not yet implemented: {str(e)}\n\n"
"To fix this issue:\n"
" 1. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token (without 'enc:' prefix)\n"
" 2. Or re-authenticate with: claude setup-token"
)
except ValueError:
# Re-raise ValueError as-is (already has good error message)
raise
except FileNotFoundError as e:
# File-related errors (missing credentials file, missing binary)
raise ValueError(
f"Failed to decrypt token - required file not found: {str(e)}\n\n"
"To fix this issue:\n"
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
)
except PermissionError as e:
# Permission errors (can't access keychain, credential manager, etc.)
raise ValueError(
f"Failed to decrypt token - permission denied: {str(e)}\n\n"
"To fix this issue:\n"
" 1. Grant keychain/credential manager access to this application\n"
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
)
except subprocess.TimeoutExpired:
# Timeout during decryption process
raise ValueError(
"Failed to decrypt token - operation timed out.\n\n"
"This may indicate a problem with system keychain access.\n\n"
"To fix this issue:\n"
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
)
except Exception as e:
# Catch-all for other errors - provide helpful error message
error_type = type(e).__name__
raise ValueError(
f"Failed to decrypt token ({error_type}): {str(e)}\n\n"
"To fix this issue:\n"
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file\n\n"
"Note: Encrypted tokens (enc:...) require the Claude Code CLI to be installed\n"
"and properly configured with system keychain access."
)
def _decrypt_token_macos(encrypted_data: str) -> str:
"""
Decrypt token on macOS using Keychain.
Args:
encrypted_data: Encrypted token data (without 'enc:' prefix)
Returns:
Decrypted token
Raises:
ValueError: If decryption fails or Claude CLI not available
"""
# Verify Claude CLI is installed (required for future decryption implementation)
if not shutil.which("claude"):
raise ValueError(
"Claude Code CLI not found. Please install it from https://code.claude.com"
)
# The Claude Code CLI handles token decryption internally when it runs
# We can trigger this by running a simple command that requires authentication
# and capturing the decrypted token from the environment it sets up
#
# However, there's no direct CLI command to decrypt tokens.
# The SDK should handle this automatically when it receives encrypted tokens.
raise NotImplementedError(
"Encrypted tokens in environment variables are not supported. "
"Please use one of these options:\n"
" 1. Run 'claude setup-token' to store token in system keychain\n"
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
"Note: This requires Claude Agent SDK >= 0.1.19"
)
def _decrypt_token_linux(encrypted_data: str) -> str:
"""
Decrypt token on Linux using Secret Service API.
Args:
encrypted_data: Encrypted token data (without 'enc:' prefix)
Returns:
Decrypted token
Raises:
ValueError: If decryption fails or dependencies not available
"""
# Linux token decryption requires secretstorage library
if secretstorage is None:
raise ValueError(
"secretstorage library not found. Install it with: pip install secretstorage"
)
# Similar to macOS, the actual decryption mechanism isn't publicly documented
# The Claude Agent SDK should handle this automatically
raise NotImplementedError(
"Encrypted tokens in environment variables are not supported. "
"Please use one of these options:\n"
" 1. Run 'claude setup-token' to store token in system keychain\n"
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
"Note: This requires Claude Agent SDK >= 0.1.19"
)
def _decrypt_token_windows(encrypted_data: str) -> str:
"""
Decrypt token on Windows using Credential Manager.
Args:
encrypted_data: Encrypted token data (without 'enc:' prefix)
Returns:
Decrypted token
Raises:
ValueError: If decryption fails
"""
# Windows token decryption from Credential Manager or .credentials.json
# The Claude Agent SDK should handle this automatically
raise NotImplementedError(
"Encrypted tokens in environment variables are not supported. "
"Please use one of these options:\n"
" 1. Run 'claude setup-token' to store token in system keychain\n"
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
"Note: This requires Claude Agent SDK >= 0.1.19"
)
def _try_decrypt_token(token: str | None) -> str | None:
"""
Attempt to decrypt an encrypted token, returning original if decryption fails.
This helper centralizes the decrypt-or-return-as-is logic used when resolving
tokens from various sources (env vars, config dir, keychain).
Args:
token: Token string (may be encrypted with "enc:" prefix, plaintext, or None)
Returns:
- Decrypted token if successfully decrypted
- Original token if decryption fails (allows client validation to report error)
- Original token if not encrypted
- None if token is None
"""
if not token:
return None
if is_encrypted_token(token):
try:
return decrypt_token(token)
except ValueError:
# Decryption failed - return encrypted token so client validation
# (validate_token_not_encrypted) can provide specific error message.
return token
return token
def get_token_from_keychain() -> str | None:
"""
Get authentication token from system credential store.
@@ -48,20 +353,18 @@ def get_token_from_keychain() -> str | None:
Reads Claude Code credentials from:
- macOS: Keychain
- Windows: Credential Manager
- Linux: Not yet supported (use env var)
- Linux: Secret Service API (via dbus/secretstorage)
Returns:
Token string if found, None otherwise
"""
system = platform.system()
if system == "Darwin":
if is_macos():
return _get_token_from_macos_keychain()
elif system == "Windows":
elif is_windows():
return _get_token_from_windows_credential_files()
else:
# Linux: secret-service not yet implemented
return None
# Linux: use secret-service API via DBus
return _get_token_from_linux_secret_service()
def _get_token_from_macos_keychain() -> str | None:
@@ -131,59 +434,216 @@ def _get_token_from_windows_credential_files() -> str | None:
return None
def get_auth_token() -> str | None:
"""
Get authentication token from environment variables or system credential store.
def _get_token_from_linux_secret_service() -> str | None:
"""Get token from Linux Secret Service API via DBus.
Checks multiple sources in priority order:
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
3. System credential store (macOS Keychain, Windows Credential Manager)
Claude Code on Linux stores credentials in the Secret Service API
using the 'org.freedesktop.secrets' collection. This implementation
uses the secretstorage library which communicates via DBus.
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
silent billing to user's API credits when OAuth is misconfigured.
The credential is stored with:
- Label: "Claude Code-credentials"
- Attributes: {application: "claude-code"}
Returns:
Token string if found, None otherwise
"""
# First check environment variables
if secretstorage is None:
# secretstorage not installed, fall back to env var
return None
try:
# Get the default collection (typically "login" keyring)
# secretstorage handles DBus communication internally
try:
collection = secretstorage.get_default_collection(None)
except (
AttributeError,
secretstorage.exceptions.SecretServiceNotAvailableException,
):
# DBus not available or secret-service not running
return None
if collection.is_locked():
# Try to unlock the collection (may prompt user for password)
try:
collection.unlock()
except secretstorage.exceptions.SecretStorageException:
# User cancelled or unlock failed
return None
# Search for items with our application attribute
items = collection.search_items({"application": "claude-code"})
for item in items:
# Check if this is the Claude Code credentials item
label = item.get_label()
# Use exact match for "Claude Code-credentials" to avoid false positives
if label == "Claude Code-credentials":
# Get the secret (stored as JSON string)
secret = item.get_secret()
if not secret:
continue
try:
# Explicitly decode bytes to string if needed
if isinstance(secret, bytes):
secret = secret.decode("utf-8")
data = json.loads(secret)
token = data.get("claudeAiOauth", {}).get("accessToken")
if token and token.startswith("sk-ant-oat01-"):
return token
except json.JSONDecodeError:
continue
return None
except (
secretstorage.exceptions.SecretStorageException,
json.JSONDecodeError,
KeyError,
AttributeError,
TypeError,
):
# Any error with secret-service, fall back to env var
return None
def _get_token_from_config_dir(config_dir: str) -> str | None:
"""
Read token from a custom config directory's credentials file.
Claude Code stores credentials in .credentials.json within the config directory.
This function reads from a profile's custom configDir instead of the default location.
Args:
config_dir: Path to the config directory (e.g., ~/.auto-claude/profiles/work)
Returns:
Token string if found, None otherwise
"""
# Expand ~ if present
expanded_dir = os.path.expanduser(config_dir)
# Claude stores credentials in these files within the config dir
cred_files = [
os.path.join(expanded_dir, ".credentials.json"),
os.path.join(expanded_dir, "credentials.json"),
]
for cred_path in cred_files:
if os.path.exists(cred_path):
try:
with open(cred_path, encoding="utf-8") as f:
data = json.load(f)
# Try both credential structures
oauth_data = data.get("claudeAiOauth") or data.get("oauthAccount") or {}
token = oauth_data.get("accessToken")
# Accept both plaintext tokens (sk-ant-oat01-) and encrypted tokens (enc:)
if token and (
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
):
logger.debug(f"Found token in {cred_path}")
return token
except (json.JSONDecodeError, KeyError, Exception) as e:
logger.debug(f"Failed to read {cred_path}: {e}")
continue
return None
def get_auth_token(config_dir: str | None = None) -> str | None:
"""
Get authentication token from environment variables or credential store.
Args:
config_dir: Optional custom config directory (profile's configDir).
If provided, reads credentials from this directory.
If None, checks CLAUDE_CONFIG_DIR env var, then uses default locations.
Checks multiple sources in priority order:
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
3. Custom config directory (config_dir param or CLAUDE_CONFIG_DIR env var)
4. System credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service)
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
silent billing to user's API credits when OAuth is misconfigured.
If the token has an "enc:" prefix (encrypted format), it will be automatically
decrypted before being returned.
Returns:
Token string if found, None otherwise
"""
# First check environment variables (highest priority)
for var in AUTH_TOKEN_ENV_VARS:
token = os.environ.get(var)
if token:
return token
return _try_decrypt_token(token)
# Fallback to system credential store
return get_token_from_keychain()
# Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory)
env_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
effective_config_dir = config_dir or env_config_dir
# If a custom config directory is specified, read from there
if effective_config_dir:
token = _get_token_from_config_dir(effective_config_dir)
if token:
return _try_decrypt_token(token)
# Fallback to system credential store (default locations)
return _try_decrypt_token(get_token_from_keychain())
def get_auth_token_source() -> str | None:
"""Get the name of the source that provided the auth token."""
def get_auth_token_source(config_dir: str | None = None) -> str | None:
"""
Get the name of the source that provided the auth token.
Args:
config_dir: Optional custom config directory (profile's configDir).
If provided, checks this directory for credentials.
If None, checks CLAUDE_CONFIG_DIR env var.
"""
# Check environment variables first
for var in AUTH_TOKEN_ENV_VARS:
if os.environ.get(var):
return var
# Check if token came from custom config directory (profile's configDir)
env_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
effective_config_dir = config_dir or env_config_dir
if effective_config_dir and _get_token_from_config_dir(effective_config_dir):
return "CLAUDE_CONFIG_DIR"
# Check if token came from system credential store
if get_token_from_keychain():
system = platform.system()
if system == "Darwin":
if is_macos():
return "macOS Keychain"
elif system == "Windows":
elif is_windows():
return "Windows Credential Files"
else:
return "System Credential Store"
return "Linux Secret Service"
return None
def require_auth_token() -> str:
def require_auth_token(config_dir: str | None = None) -> str:
"""
Get authentication token or raise ValueError.
Args:
config_dir: Optional custom config directory (profile's configDir).
If provided, reads credentials from this directory.
If None, checks CLAUDE_CONFIG_DIR env var, then uses default locations.
Raises:
ValueError: If no auth token is found in any supported source
"""
token = get_auth_token()
token = get_auth_token(config_dir)
if not token:
error_msg = (
"No OAuth token found.\n\n"
@@ -191,27 +651,33 @@ def require_auth_token() -> str:
"Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n"
)
# Provide platform-specific guidance
system = platform.system()
if system == "Darwin":
if is_macos():
error_msg += (
"To authenticate:\n"
" 1. Run: claude setup-token\n"
" 2. The token will be saved to macOS Keychain automatically\n\n"
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
" 1. Run: claude\n"
" 2. Type: /login\n"
" 3. Press Enter to open browser\n"
" 4. Complete OAuth login in browser\n\n"
"The token will be saved to macOS Keychain automatically."
)
elif system == "Windows":
elif is_windows():
error_msg += (
"To authenticate:\n"
" 1. Run: claude setup-token\n"
" 2. The token should be saved to Windows Credential Manager\n\n"
"If auto-detection fails, set CLAUDE_CODE_OAUTH_TOKEN in your .env file.\n"
"Check: %LOCALAPPDATA%\\Claude\\credentials.json"
" 1. Run: claude\n"
" 2. Type: /login\n"
" 3. Press Enter to open browser\n"
" 4. Complete OAuth login in browser\n\n"
"The token will be saved to Windows Credential Manager."
)
else:
# Linux
error_msg += (
"To authenticate:\n"
" 1. Run: claude setup-token\n"
" 2. Set CLAUDE_CODE_OAUTH_TOKEN in your .env file"
" 1. Run: claude\n"
" 2. Type: /login\n"
" 3. Press Enter to open browser\n"
" 4. Complete OAuth login in browser\n\n"
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
)
raise ValueError(error_msg)
return token
@@ -228,7 +694,7 @@ def _find_git_bash_path() -> str | None:
Returns:
Full path to bash.exe if found, None otherwise
"""
if platform.system() != "Windows":
if not is_windows():
return None
# If already set in environment, use that
@@ -316,11 +782,21 @@ def get_sdk_env_vars() -> dict[str, str]:
# On Windows, auto-detect git-bash path if not already set
# Claude Code CLI requires bash.exe to run on Windows
if platform.system() == "Windows" and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
if is_windows() and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
bash_path = _find_git_bash_path()
if bash_path:
env["CLAUDE_CODE_GIT_BASH_PATH"] = bash_path
# Explicitly unset PYTHONPATH in SDK subprocess environment to prevent
# pollution of agent subprocess environments. This fixes ACS-251 where
# external projects with different Python versions would fail due to
# inheriting Auto-Claude's PYTHONPATH (which points to Python 3.12 packages).
#
# The SDK merges os.environ with the env dict we provide, so setting
# PYTHONPATH to an empty string here overrides any inherited value.
# The empty string ensures Python doesn't add any extra paths to sys.path.
env["PYTHONPATH"] = ""
return env
@@ -337,3 +813,181 @@ def ensure_claude_code_oauth_token() -> None:
token = get_auth_token()
if token:
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token
def trigger_login() -> bool:
"""
Trigger Claude Code OAuth login flow.
Opens the Claude Code CLI and sends /login command to initiate
browser-based OAuth authentication. The token is automatically
saved to the system credential store (macOS Keychain, Windows
Credential Manager).
Returns:
True if login was successful, False otherwise
"""
if is_macos():
return _trigger_login_macos()
elif is_windows():
return _trigger_login_windows()
else:
# Linux: fall back to manual instructions
print("\nTo authenticate, run 'claude' and type '/login'")
return False
def _trigger_login_macos() -> bool:
"""Trigger login on macOS using expect."""
import shutil
import tempfile
# Check if expect is available
if not shutil.which("expect"):
print("\nTo authenticate, run 'claude' and type '/login'")
return False
# Create expect script
expect_script = """#!/usr/bin/expect -f
set timeout 120
spawn claude
expect {
-re ".*" {
send "/login\\r"
expect {
"Press Enter" {
send "\\r"
}
-re ".*login.*" {
send "\\r"
}
timeout {
send "\\r"
}
}
}
}
# Keep running until user completes login or exits
interact
"""
# Use TemporaryDirectory context manager for automatic cleanup
# This prevents information leakage about authentication activity
# Directory created with mode 0o700 (owner read/write/execute only)
try:
with tempfile.TemporaryDirectory() as temp_dir:
# Ensure directory has owner-only permissions
os.chmod(temp_dir, 0o700)
# Write expect script to temp file in our private directory
script_path = os.path.join(temp_dir, "login.exp")
with open(script_path, "w", encoding="utf-8") as f:
f.write(expect_script)
# Set script permissions to owner-only (0o700)
os.chmod(script_path, 0o700)
print("\n" + "=" * 60)
print("CLAUDE CODE LOGIN")
print("=" * 60)
print("\nOpening Claude Code for authentication...")
print("A browser window will open for OAuth login.")
print("After completing login in the browser, press Ctrl+C to exit.\n")
# Run expect script
subprocess.run(
["expect", script_path],
timeout=300, # 5 minute timeout
)
# Verify token was saved
token = get_token_from_keychain()
if token:
print("\n✓ Login successful! Token saved to macOS Keychain.")
return True
else:
print(
"\n✗ Login may not have completed. Try running 'claude' and type '/login'"
)
return False
except subprocess.TimeoutExpired:
print("\nLogin timed out. Try running 'claude' manually and type '/login'")
return False
except KeyboardInterrupt:
# User pressed Ctrl+C - check if login completed
token = get_token_from_keychain()
if token:
print("\n✓ Login successful! Token saved to macOS Keychain.")
return True
return False
except Exception as e:
print(f"\nLogin failed: {e}")
print("Try running 'claude' manually and type '/login'")
return False
def _trigger_login_windows() -> bool:
"""Trigger login on Windows."""
# Windows doesn't have expect by default, so we use a simpler approach
# that just launches claude and tells the user what to type
print("\n" + "=" * 60)
print("CLAUDE CODE LOGIN")
print("=" * 60)
print("\nLaunching Claude Code...")
print("Please type '/login' and press Enter.")
print("A browser window will open for OAuth login.\n")
try:
# Launch claude interactively
subprocess.run(["claude"], timeout=300)
# Verify token was saved
token = _get_token_from_windows_credential_files()
if token:
print("\n✓ Login successful!")
return True
else:
print("\n✗ Login may not have completed.")
return False
except Exception as e:
print(f"\nLogin failed: {e}")
return False
def ensure_authenticated() -> str:
"""
Ensure the user is authenticated, prompting for login if needed.
Checks for existing token and triggers login flow if not found.
Returns:
The authentication token
Raises:
ValueError: If authentication fails after login attempt
"""
# First check if already authenticated
token = get_auth_token()
if token:
return token
# No token found - trigger login
print("\nNo OAuth token found. Starting login flow...")
if trigger_login():
# Re-check for token after login
token = get_auth_token()
if token:
return token
# Login failed or was cancelled
raise ValueError(
"Authentication required.\n\n"
"To authenticate:\n"
" 1. Run: claude\n"
" 2. Type: /login\n"
" 3. Press Enter to open browser\n"
" 4. Complete OAuth login in browser"
)
+25 -301
View File
@@ -16,14 +16,16 @@ import copy
import json
import logging
import os
import platform
import shutil
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from core.platform import (
is_windows,
validate_cli_path,
)
logger = logging.getLogger(__name__)
# =============================================================================
@@ -123,290 +125,6 @@ def invalidate_project_cache(project_dir: Path | None = None) -> None:
logger.debug(f"Invalidated project index cache for {project_dir}")
# =============================================================================
# Claude CLI Path Detection
# =============================================================================
# Cross-platform detection of Claude Code CLI binary.
# This mirrors the frontend's cli-tool-manager.ts logic to ensure consistency.
_CLAUDE_CLI_CACHE: dict[str, str | None] = {}
_CLI_CACHE_LOCK = threading.Lock()
def _get_claude_detection_paths() -> dict[str, list[str]]:
"""
Get all candidate paths for Claude CLI detection.
Returns platform-specific paths where Claude CLI might be installed.
IMPORTANT: This function mirrors the frontend's getClaudeDetectionPaths()
in apps/frontend/src/main/cli-tool-manager.ts. Both implementations MUST
be kept in sync to ensure consistent detection behavior across the
Python backend and Electron frontend.
When adding new detection paths, update BOTH:
1. This function (_get_claude_detection_paths in client.py)
2. getClaudeDetectionPaths() in cli-tool-manager.ts
Returns:
Dict with 'homebrew', 'platform', and 'nvm' path lists
"""
home_dir = Path.home()
is_windows = platform.system() == "Windows"
homebrew_paths = [
"/opt/homebrew/bin/claude", # Apple Silicon
"/usr/local/bin/claude", # Intel Mac
]
if is_windows:
platform_paths = [
str(home_dir / "AppData" / "Local" / "Programs" / "claude" / "claude.exe"),
str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"),
str(home_dir / ".local" / "bin" / "claude.exe"),
"C:\\Program Files\\Claude\\claude.exe",
"C:\\Program Files (x86)\\Claude\\claude.exe",
]
else:
platform_paths = [
str(home_dir / ".local" / "bin" / "claude"),
str(home_dir / "bin" / "claude"),
]
nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node")
return {
"homebrew": homebrew_paths,
"platform": platform_paths,
"nvm_versions_dir": nvm_versions_dir,
}
def _is_secure_path(path_str: str) -> bool:
"""
Validate that a path doesn't contain dangerous characters.
Prevents command injection attacks by rejecting paths with shell metacharacters,
directory traversal patterns, or environment variable expansion.
Args:
path_str: Path to validate
Returns:
True if the path is safe, False otherwise
"""
import re
dangerous_patterns = [
r'[;&|`${}[\]<>!"^]', # Shell metacharacters
r"%[^%]+%", # Windows environment variable expansion
r"\.\./", # Unix directory traversal
r"\.\.\\", # Windows directory traversal
r"[\r\n]", # Newlines (command injection)
]
for pattern in dangerous_patterns:
if re.search(pattern, path_str):
return False
return True
def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]:
"""
Validate that a Claude CLI path is executable and returns a version.
Includes security validation to prevent command injection attacks.
Args:
cli_path: Path to the Claude CLI executable
Returns:
Tuple of (is_valid, version_string or None)
Note:
Cross-references with frontend's validateClaudeCliAsync() in
apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts
Both should be kept in sync for consistent behavior.
"""
import re
# Security validation: reject paths with shell metacharacters or directory traversal
if not _is_secure_path(cli_path):
logger.warning(f"Rejecting insecure Claude CLI path: {cli_path}")
return False, None
try:
is_windows = platform.system() == "Windows"
# Augment PATH with the CLI directory for proper resolution
env = os.environ.copy()
cli_dir = os.path.dirname(cli_path)
if cli_dir:
env["PATH"] = cli_dir + os.pathsep + env.get("PATH", "")
# For Windows .cmd/.bat files, use cmd.exe with proper quoting
# /d = disable AutoRun registry commands
# /s = strip first and last quotes, preserving inner quotes
# /c = run command then terminate
if is_windows and cli_path.lower().endswith((".cmd", ".bat")):
# Get cmd.exe path from environment or use default
cmd_exe = os.environ.get("ComSpec") or os.path.join(
os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe"
)
# Use double-quoted command line for paths with spaces
cmd_line = f'""{cli_path}" --version"'
result = subprocess.run(
[cmd_exe, "/d", "/s", "/c", cmd_line],
capture_output=True,
text=True,
timeout=5,
env=env,
creationflags=subprocess.CREATE_NO_WINDOW,
)
else:
result = subprocess.run(
[cli_path, "--version"],
capture_output=True,
text=True,
timeout=5,
env=env,
creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0,
)
if result.returncode == 0:
# Extract version from output (e.g., "claude-code version 1.0.0")
output = result.stdout.strip()
match = re.search(r"(\d+\.\d+\.\d+)", output)
version = match.group(1) if match else output.split("\n")[0]
return True, version
return False, None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
logger.debug(f"Claude CLI validation failed for {cli_path}: {e}")
return False, None
def find_claude_cli() -> str | None:
"""
Find the Claude Code CLI binary path.
Uses cross-platform detection with the following priority:
1. CLAUDE_CLI_PATH environment variable (user override)
2. shutil.which() - system PATH lookup
3. Homebrew paths (macOS)
4. NVM paths (Unix - checks Node.js version manager)
5. Platform-specific standard locations
Returns:
Path to Claude CLI if found and valid, None otherwise
"""
# Check cache first
cache_key = "claude_cli"
with _CLI_CACHE_LOCK:
if cache_key in _CLAUDE_CLI_CACHE:
cached = _CLAUDE_CLI_CACHE[cache_key]
logger.debug(f"Using cached Claude CLI path: {cached}")
return cached
is_windows = platform.system() == "Windows"
paths = _get_claude_detection_paths()
# 1. Check environment variable override
env_path = os.environ.get("CLAUDE_CLI_PATH")
if env_path:
if Path(env_path).exists():
valid, version = _validate_claude_cli(env_path)
if valid:
logger.info(f"Using CLAUDE_CLI_PATH: {env_path} (v{version})")
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = env_path
return env_path
logger.warning(f"CLAUDE_CLI_PATH is set but invalid: {env_path}")
# 2. Try shutil.which() - most reliable cross-platform PATH lookup
which_path = shutil.which("claude")
if which_path:
valid, version = _validate_claude_cli(which_path)
if valid:
logger.info(f"Found Claude CLI in PATH: {which_path} (v{version})")
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = which_path
return which_path
# 3. Homebrew paths (macOS)
if platform.system() == "Darwin":
for hb_path in paths["homebrew"]:
if Path(hb_path).exists():
valid, version = _validate_claude_cli(hb_path)
if valid:
logger.info(f"Found Claude CLI (Homebrew): {hb_path} (v{version})")
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = hb_path
return hb_path
# 4. NVM paths (Unix only) - check Node.js version manager installations
if not is_windows:
nvm_dir = Path(paths["nvm_versions_dir"])
if nvm_dir.exists():
try:
# Get all version directories and sort by version (newest first)
version_dirs = []
for entry in nvm_dir.iterdir():
if entry.is_dir() and entry.name.startswith("v"):
# Parse version: v20.0.0 -> (20, 0, 0)
try:
parts = entry.name[1:].split(".")
if len(parts) == 3:
version_dirs.append(
(tuple(int(p) for p in parts), entry.name)
)
except ValueError:
continue
# Sort by version descending (newest first)
version_dirs.sort(reverse=True)
for _, version_name in version_dirs:
nvm_claude = nvm_dir / version_name / "bin" / "claude"
if nvm_claude.exists():
valid, version = _validate_claude_cli(str(nvm_claude))
if valid:
logger.info(
f"Found Claude CLI (NVM): {nvm_claude} (v{version})"
)
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = str(nvm_claude)
return str(nvm_claude)
except OSError as e:
logger.debug(f"Error scanning NVM directory: {e}")
# 5. Platform-specific standard locations
for plat_path in paths["platform"]:
if Path(plat_path).exists():
valid, version = _validate_claude_cli(plat_path)
if valid:
logger.info(f"Found Claude CLI: {plat_path} (v{version})")
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = plat_path
return plat_path
# Not found
logger.warning(
"Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
)
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE[cache_key] = None
return None
def clear_claude_cli_cache() -> None:
"""Clear the Claude CLI path cache, forcing re-detection on next call."""
with _CLI_CACHE_LOCK:
_CLAUDE_CLI_CACHE.clear()
logger.debug("Claude CLI cache cleared")
from agents.tools_pkg import (
CONTEXT7_TOOLS,
ELECTRON_TOOLS,
@@ -420,7 +138,11 @@ from agents.tools_pkg import (
)
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from claude_agent_sdk.types import HookMatcher
from core.auth import get_sdk_env_vars, require_auth_token
from core.auth import (
get_sdk_env_vars,
require_auth_token,
validate_token_not_encrypted,
)
from linear_updater import is_linear_enabled
from prompts_pkg.project_context import detect_project_capabilities, load_project_index
from security import bash_security_hook
@@ -768,7 +490,14 @@ def create_client(
(see security.py for ALLOWED_COMMANDS)
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
"""
# Get OAuth token - Claude CLI handles token lifecycle internally
oauth_token = require_auth_token()
# Validate token is not encrypted before passing to SDK
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
# If we still have an encrypted token here, it means decryption failed or was skipped
validate_token_not_encrypted(oauth_token)
# Ensure SDK can access it via its expected env var
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
@@ -778,7 +507,7 @@ def create_client(
# Debug: Log git-bash path detection on Windows
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
elif platform.system() == "Windows":
elif is_windows():
logger.warning("Git Bash path not detected on Windows!")
# Check if Linear integration is enabled
@@ -928,7 +657,7 @@ def create_client(
# Write settings to a file in the project directory
settings_file = project_dir / ".claude_settings.json"
with open(settings_file, "w") as f:
with open(settings_file, "w", encoding="utf-8") as f:
json.dump(security_settings, f, indent=2)
print(f"Security settings: {settings_file}")
@@ -1066,14 +795,6 @@ def create_client(
print(" - CLAUDE.md: disabled by project settings")
print()
# Find Claude CLI path for SDK
# This ensures the SDK can find the Claude Code binary even if it's not in PATH
cli_path = find_claude_cli()
if cli_path:
print(f" - Claude CLI: {cli_path}")
else:
print(" - Claude CLI: using SDK default detection")
# Build options dict, conditionally including output_format
options_kwargs: dict[str, Any] = {
"model": model,
@@ -1098,9 +819,12 @@ def create_client(
"enable_file_checkpointing": True,
}
# Add CLI path if found (helps SDK find Claude Code in non-standard locations)
if cli_path:
options_kwargs["cli_path"] = cli_path
# Optional: Allow CLI path override via environment variable
# The SDK bundles its own CLI, but users can override if needed
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
if env_cli_path and validate_cli_path(env_cli_path):
options_kwargs["cli_path"] = env_cli_path
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
# Add structured output format if specified
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
+1 -1
View File
@@ -110,7 +110,7 @@ def _write_log(message: str, to_file: bool = True) -> None:
import re
clean_message = re.sub(r"\033\[[0-9;]*m", "", message)
with open(log_file, "a") as f:
with open(log_file, "a", encoding="utf-8") as f:
f.write(clean_message + "\n")
except Exception:
pass # Silently fail file logging
+93 -9
View File
@@ -8,6 +8,8 @@ Validates platform-specific dependencies are installed before running agents.
import sys
from pathlib import Path
from core.platform import is_linux, is_windows
def validate_platform_dependencies() -> None:
"""
@@ -17,34 +19,116 @@ def validate_platform_dependencies() -> None:
SystemExit: If required platform-specific dependencies are missing,
with helpful installation instructions.
"""
# Check Windows-specific dependencies
if sys.platform == "win32" and sys.version_info >= (3, 12):
# Check Windows-specific dependencies (all Python versions per ACS-306)
# pywin32 is required on all Python versions on Windows - MCP library unconditionally imports win32api
if is_windows():
try:
import pywintypes # noqa: F401
except ImportError:
_exit_with_pywin32_error()
# Check Linux-specific dependencies (ACS-310)
# Note: secretstorage is optional for app functionality (falls back to .env),
# but we validate it to ensure proper OAuth token storage via keyring
if is_linux():
try:
import secretstorage # noqa: F401
except ImportError:
_warn_missing_secretstorage()
def _exit_with_pywin32_error() -> None:
"""Exit with helpful error message for missing pywin32."""
# Use sys.prefix to detect the virtual environment path
# This works for venv and poetry environments
venv_activate = Path(sys.prefix) / "Scripts" / "activate"
# Check for common Windows activation scripts (activate, activate.bat, Activate.ps1)
scripts_dir = Path(sys.prefix) / "Scripts"
activation_candidates = [
scripts_dir / "activate",
scripts_dir / "activate.bat",
scripts_dir / "Activate.ps1",
]
venv_activate = next((p for p in activation_candidates if p.exists()), None)
# Build activation step only if activate script exists
activation_step = ""
if venv_activate:
activation_step = (
"To fix this:\n"
"1. Activate your virtual environment:\n"
f" {venv_activate}\n"
"\n"
"2. Install pywin32:\n"
" pip install pywin32>=306\n"
"\n"
" Or reinstall all dependencies:\n"
" pip install -r requirements.txt\n"
)
else:
# For system Python or environments without activate script
activation_step = (
"To fix this:\n"
"Install pywin32:\n"
" pip install pywin32>=306\n"
"\n"
" Or reinstall all dependencies:\n"
" pip install -r requirements.txt\n"
)
sys.exit(
"Error: Required Windows dependency 'pywin32' is not installed.\n"
"\n"
"Auto Claude requires pywin32 on Windows for LadybugDB/Graphiti memory integration.\n"
"Auto Claude requires pywin32 on Windows for:\n"
" - MCP library (win32api, win32con, win32job modules)\n"
" - LadybugDB/Graphiti memory integration\n"
"\n"
"To fix this:\n"
"1. Activate your virtual environment:\n"
f" {venv_activate}\n"
f"{activation_step}"
"\n"
"2. Install pywin32:\n"
" pip install pywin32>=306\n"
f"Current Python: {sys.executable}\n"
)
def _warn_missing_secretstorage() -> None:
"""Emit warning message for missing secretstorage.
Note: This is a warning, not a hard error - the app will fall back to .env
file storage for OAuth tokens. We warn users to ensure they understand the
security implications.
"""
# Use sys.prefix to detect the virtual environment path
venv_activate = Path(sys.prefix) / "bin" / "activate"
# Only include activation instruction if venv script actually exists
activation_prefix = (
f"1. Activate your virtual environment:\n source {venv_activate}\n\n"
if venv_activate.exists()
else ""
)
# Adjust step number based on whether activation step is included
install_step = (
"2. Install secretstorage:\n"
if activation_prefix
else "Install secretstorage:\n"
)
sys.stderr.write(
"Warning: Linux dependency 'secretstorage' is not installed.\n"
"\n"
"Auto Claude can use secretstorage for secure OAuth token storage via\n"
"the system keyring (gnome-keyring, kwallet, etc.). Without it, tokens\n"
"will be stored in plaintext in your .env file.\n"
"\n"
"To enable keyring integration:\n"
f"{activation_prefix}"
f"{install_step}"
" pip install 'secretstorage>=3.3.3'\n"
"\n"
" Or reinstall all dependencies:\n"
" pip install -r requirements.txt\n"
"\n"
"Note: The app will continue to work, but OAuth tokens will be stored\n"
"in your .env file instead of the system keyring.\n"
"\n"
f"Current Python: {sys.executable}\n"
)
sys.stderr.flush()
# Continue execution - this is a warning, not a blocking error
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
GitHub CLI Executable Finder
============================
Utility to find the gh (GitHub CLI) executable, with platform-specific fallbacks.
"""
import os
import shutil
import subprocess
_cached_gh_path: str | None = None
def invalidate_gh_cache() -> None:
"""Invalidate the cached gh executable path.
Useful when gh may have been uninstalled, updated, or when
GITHUB_CLI_PATH environment variable has changed.
"""
global _cached_gh_path
_cached_gh_path = None
def _verify_gh_executable(path: str) -> bool:
"""Verify that a path is a valid gh executable by checking version.
Args:
path: Path to the potential gh executable
Returns:
True if the path points to a valid gh executable, False otherwise
"""
try:
result = subprocess.run(
[path, "--version"],
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
def _run_where_command() -> str | None:
"""Run Windows 'where gh' command to find gh executable.
Returns:
First path found, or None if command failed
"""
try:
result = subprocess.run(
"where gh",
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
shell=True, # Required: 'where' command must be executed through shell on Windows
)
if result.returncode == 0 and result.stdout.strip():
found_path = result.stdout.strip().split("\n")[0].strip()
if (
found_path
and os.path.isfile(found_path)
and _verify_gh_executable(found_path)
):
return found_path
except (subprocess.TimeoutExpired, OSError):
# 'where' command failed or timed out - fall through to return None
pass
return None
def get_gh_executable() -> str | None:
"""Find the gh executable, with platform-specific fallbacks.
Returns the path to gh executable, or None if not found.
Priority order:
1. GITHUB_CLI_PATH env var (user-configured path from frontend)
2. shutil.which (if gh is in PATH)
3. Homebrew paths on macOS
4. Windows Program Files paths
5. Windows 'where' command
Caches the result after first successful find. Use invalidate_gh_cache()
to force re-detection (e.g., after gh installation/uninstallation).
"""
global _cached_gh_path
# Return cached result if available AND still exists
if _cached_gh_path is not None and os.path.isfile(_cached_gh_path):
return _cached_gh_path
_cached_gh_path = _find_gh_executable()
return _cached_gh_path
def _find_gh_executable() -> str | None:
"""Internal function to find gh executable."""
# 1. Check GITHUB_CLI_PATH env var (set by Electron frontend)
env_path = os.environ.get("GITHUB_CLI_PATH")
if env_path and os.path.isfile(env_path) and _verify_gh_executable(env_path):
return env_path
# 2. Try shutil.which (works if gh is in PATH)
gh_path = shutil.which("gh")
if gh_path and _verify_gh_executable(gh_path):
return gh_path
# 3. macOS-specific: check Homebrew paths
if os.name != "nt": # Unix-like systems (macOS, Linux)
homebrew_paths = [
"/opt/homebrew/bin/gh", # Apple Silicon
"/usr/local/bin/gh", # Intel Mac
"/home/linuxbrew/.linuxbrew/bin/gh", # Linux Homebrew
]
for path in homebrew_paths:
if os.path.isfile(path) and _verify_gh_executable(path):
return path
# 4. Windows-specific: check Program Files paths
if os.name == "nt":
windows_paths = [
os.path.expandvars(r"%PROGRAMFILES%\GitHub CLI\gh.exe"),
os.path.expandvars(r"%PROGRAMFILES(X86)%\GitHub CLI\gh.exe"),
os.path.expandvars(r"%LOCALAPPDATA%\Programs\GitHub CLI\gh.exe"),
]
for path in windows_paths:
if os.path.isfile(path) and _verify_gh_executable(path):
return path
# 5. Try 'where' command with shell=True (more reliable on Windows)
return _run_where_command()
return None
def run_gh(
args: list[str],
cwd: str | None = None,
timeout: int = 60,
input_data: str | None = None,
) -> subprocess.CompletedProcess:
"""Run a gh command with proper executable finding.
Args:
args: gh command arguments (without 'gh' prefix)
cwd: Working directory for the command
timeout: Command timeout in seconds (default: 60)
input_data: Optional string data to pass to stdin
Returns:
CompletedProcess with command results.
"""
gh = get_gh_executable()
if not gh:
return subprocess.CompletedProcess(
args=["gh"] + args,
returncode=-1,
stdout="",
stderr="GitHub CLI (gh) not found. Install from https://cli.github.com/",
)
try:
return subprocess.run(
[gh] + args,
cwd=cwd,
input=input_data,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
args=[gh] + args,
returncode=-1,
stdout="",
stderr=f"Command timed out after {timeout} seconds",
)
except FileNotFoundError:
return subprocess.CompletedProcess(
args=[gh] + args,
returncode=-1,
stdout="",
stderr="GitHub CLI (gh) executable not found. Install from https://cli.github.com/",
)
+59 -3
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
"""
Git Executable Finder
======================
Git Executable Finder and Isolation
====================================
Utility to find the git executable, with Windows-specific fallbacks.
Also provides environment isolation to prevent pre-commit hooks and
other git configurations from affecting worktree operations.
Separated into its own module to avoid circular imports.
"""
@@ -12,9 +15,53 @@ import shutil
import subprocess
from pathlib import Path
# Git environment variables that can interfere with worktree operations
# when set by pre-commit hooks or other git configurations.
# These must be cleared to prevent cross-worktree contamination.
GIT_ENV_VARS_TO_CLEAR = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
# Identity variables that could be set by hooks
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_AUTHOR_DATE",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
"GIT_COMMITTER_DATE",
]
_cached_git_path: str | None = None
def get_isolated_git_env(base_env: dict | None = None) -> dict:
"""
Create an isolated environment for git operations.
Clears git environment variables that may be set by pre-commit hooks
or other git configurations, preventing cross-worktree contamination
and ensuring git operations target the intended repository.
Args:
base_env: Base environment dict to copy from. If None, uses os.environ.
Returns:
Environment dict safe for git subprocess operations.
"""
env = dict(base_env) if base_env is not None else os.environ.copy()
for key in GIT_ENV_VARS_TO_CLEAR:
env.pop(key, None)
# Disable user's pre-commit hooks during Auto-Claude managed git operations
# to prevent double-hook execution and potential conflicts
env["HUSKY"] = "0"
return env
def get_git_executable() -> str:
"""Find the git executable, with Windows-specific fallbacks.
@@ -102,19 +149,27 @@ def run_git(
cwd: Path | str | None = None,
timeout: int = 60,
input_data: str | None = None,
env: dict | None = None,
isolate_env: bool = True,
) -> subprocess.CompletedProcess:
"""Run a git command with proper executable finding.
"""Run a git command with proper executable finding and environment isolation.
Args:
args: Git command arguments (without 'git' prefix)
cwd: Working directory for the command
timeout: Command timeout in seconds (default: 60)
input_data: Optional string data to pass to stdin
env: Custom environment dict. If None and isolate_env=True, uses isolated env.
isolate_env: If True (default), clears git env vars to prevent hook interference.
Returns:
CompletedProcess with command results.
"""
git = get_git_executable()
if env is None and isolate_env:
env = get_isolated_git_env()
try:
return subprocess.run(
[git] + args,
@@ -125,6 +180,7 @@ def run_git(
encoding="utf-8",
errors="replace",
timeout=timeout,
env=env,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
+516
View File
@@ -0,0 +1,516 @@
"""
Platform Abstraction Layer
Centralized platform-specific operations for the Python backend.
All code that checks sys.platform or handles OS differences should use this module.
Design principles:
- Single source of truth for platform detection
- Feature detection over platform detection when possible
- Clear, intention-revealing names
- Immutable configurations where possible
"""
import os
import platform
import re
import shutil
import subprocess
from enum import Enum
from pathlib import Path
# ============================================================================
# Type Definitions
# ============================================================================
class OS(Enum):
"""Supported operating systems."""
WINDOWS = "Windows"
MACOS = "Darwin"
LINUX = "Linux"
class ShellType(Enum):
"""Available shell types."""
POWERSHELL = "powershell"
CMD = "cmd"
BASH = "bash"
ZSH = "zsh"
FISH = "fish"
UNKNOWN = "unknown"
# ============================================================================
# Platform Detection
# ============================================================================
def get_current_os() -> OS:
"""Get the current operating system.
Returns the OS enum for the current platform. For unsupported Unix-like
systems (e.g., FreeBSD, SunOS), defaults to Linux for compatibility.
"""
system = platform.system()
if system == "Windows":
return OS.WINDOWS
elif system == "Darwin":
return OS.MACOS
# Default to Linux for other Unix-like systems (FreeBSD, SunOS, etc.)
return OS.LINUX
def is_windows() -> bool:
"""Check if running on Windows."""
return platform.system() == "Windows"
def is_macos() -> bool:
"""Check if running on macOS."""
return platform.system() == "Darwin"
def is_linux() -> bool:
"""Check if running on Linux."""
return platform.system() == "Linux"
def is_unix() -> bool:
"""Check if running on a Unix-like system (macOS or Linux)."""
return not is_windows()
# ============================================================================
# Path Configuration
# ============================================================================
def get_path_delimiter() -> str:
"""Get the PATH separator for environment variables."""
return ";" if is_windows() else ":"
def get_executable_extension() -> str:
"""Get the default file extension for executables."""
return ".exe" if is_windows() else ""
def with_executable_extension(base_name: str) -> str:
"""Add executable extension to a base name if needed."""
if not base_name:
return base_name
# Check if already has extension
if os.path.splitext(base_name)[1]:
return base_name
exe_ext = get_executable_extension()
return f"{base_name}{exe_ext}" if exe_ext else base_name
# ============================================================================
# Binary Directories
# ============================================================================
def get_binary_directories() -> dict[str, list[str]]:
"""
Get common binary directories for the current platform.
Returns:
Dict with 'user' and 'system' keys containing lists of directories.
"""
home_dir = Path.home()
if is_windows():
return {
"user": [
str(home_dir / "AppData" / "Local" / "Programs"),
str(home_dir / "AppData" / "Roaming" / "npm"),
str(home_dir / ".local" / "bin"),
],
"system": [
os.environ.get("ProgramFiles", "C:\\Program Files"),
os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"),
os.path.join(os.environ.get("SystemRoot", "C:\\Windows"), "System32"),
],
}
if is_macos():
return {
"user": [
str(home_dir / ".local" / "bin"),
str(home_dir / "bin"),
],
"system": [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
],
}
# Linux
return {
"user": [
str(home_dir / ".local" / "bin"),
str(home_dir / "bin"),
],
"system": [
"/usr/bin",
"/usr/local/bin",
"/snap/bin",
],
}
def get_homebrew_path() -> str | None:
"""
Get Homebrew binary directory (macOS only).
Returns:
Homebrew bin path or None if not on macOS.
"""
if not is_macos():
return None
homebrew_paths = [
"/opt/homebrew/bin", # Apple Silicon
"/usr/local/bin", # Intel
]
for brew_path in homebrew_paths:
if os.path.exists(brew_path):
return brew_path
return homebrew_paths[0] # Default to Apple Silicon
# ============================================================================
# Tool Detection
# ============================================================================
def find_executable(name: str, additional_paths: list[str] | None = None) -> str | None:
"""
Find an executable in standard locations.
Searches:
1. System PATH
2. Platform-specific binary directories
3. Additional custom paths
Args:
name: Name of the executable (without extension)
additional_paths: Optional list of additional paths to search
Returns:
Full path to executable if found, None otherwise
"""
# First check system PATH
in_path = shutil.which(name)
if in_path:
return in_path
# Check with extension on Windows
if is_windows():
for ext in [".exe", ".cmd", ".bat"]:
in_path = shutil.which(f"{name}{ext}")
if in_path:
return in_path
# Search in platform-specific directories
bins = get_binary_directories()
search_dirs = bins["user"] + bins["system"]
if additional_paths:
search_dirs.extend(additional_paths)
for directory in search_dirs:
if not os.path.isdir(directory):
continue
# Try without extension
exe_path = os.path.join(directory, with_executable_extension(name))
if os.path.isfile(exe_path):
return exe_path
# Try common extensions on Windows
if is_windows():
for ext in [".exe", ".cmd", ".bat"]:
exe_path = os.path.join(directory, f"{name}{ext}")
if os.path.isfile(exe_path):
return exe_path
return None
def get_claude_detection_paths() -> list[str]:
"""
Get platform-specific paths for Claude CLI detection.
Returns:
List of possible Claude CLI executable paths.
"""
home_dir = Path.home()
paths = []
if is_windows():
paths.extend(
[
str(
home_dir
/ "AppData"
/ "Local"
/ "Programs"
/ "claude"
/ "claude.exe"
),
str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"),
str(home_dir / ".local" / "bin" / "claude.exe"),
r"C:\Program Files\Claude\claude.exe",
r"C:\Program Files (x86)\Claude\claude.exe",
]
)
else:
paths.extend(
[
str(home_dir / ".local" / "bin" / "claude"),
str(home_dir / "bin" / "claude"),
]
)
# Add Homebrew path on macOS
if is_macos():
brew_path = get_homebrew_path()
if brew_path:
paths.append(os.path.join(brew_path, "claude"))
return paths
def get_claude_detection_paths_structured() -> dict[str, list[str] | str]:
"""
Get platform-specific paths for Claude CLI detection in structured format.
Returns a dict with categorized paths for different detection strategies:
- 'homebrew': Homebrew installation paths (macOS)
- 'platform': Platform-specific standard installation locations
- 'nvm_versions_dir': NVM versions directory path for scanning Node installations
This structured format allows callers to implement custom detection logic
for each category (e.g., iterating NVM version directories).
Returns:
Dict with 'homebrew', 'platform', and 'nvm_versions_dir' keys
"""
home_dir = Path.home()
homebrew_paths = [
"/opt/homebrew/bin/claude", # Apple Silicon
"/usr/local/bin/claude", # Intel Mac
]
if is_windows():
platform_paths = [
str(home_dir / "AppData/Local/Programs/claude/claude.exe"),
str(home_dir / "AppData/Roaming/npm/claude.cmd"),
str(home_dir / ".local/bin/claude.exe"),
r"C:\Program Files\Claude\claude.exe",
r"C:\Program Files (x86)\Claude\claude.exe",
]
else:
platform_paths = [
str(home_dir / ".local" / "bin" / "claude"),
str(home_dir / "bin" / "claude"),
]
nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node")
return {
"homebrew": homebrew_paths,
"platform": platform_paths,
"nvm_versions_dir": nvm_versions_dir,
}
def get_python_commands() -> list[list[str]]:
"""
Get platform-specific Python command variations as argument sequences.
Returns command arguments as sequences so callers can pass each entry
directly to subprocess.run(cmd) or use cmd[0] with shutil.which().
Returns:
List of command argument lists to try, in order of preference.
Each inner list contains the executable and any required arguments.
Example:
for cmd in get_python_commands():
if shutil.which(cmd[0]):
subprocess.run(cmd + ["--version"])
break
"""
if is_windows():
return [["py", "-3"], ["python"], ["python3"], ["py"]]
return [["python3"], ["python"]]
def validate_cli_path(cli_path: str) -> bool:
"""
Validate that a CLI path is secure and executable.
Prevents command injection attacks by rejecting paths with shell metacharacters,
directory traversal patterns, or environment variable expansion.
Args:
cli_path: Path to validate
Returns:
True if path is secure, False otherwise
"""
if not cli_path:
return False
# Security validation: reject paths with shell metacharacters or other dangerous patterns
dangerous_patterns = [
r'[;&|`${}[\]<>!"^]', # Shell metacharacters
r"%[^%]+%", # Windows environment variable expansion
r"\.\./", # Unix directory traversal
r"\.\.\\", # Windows directory traversal
r"[\r\n]", # Newlines (command injection)
]
for pattern in dangerous_patterns:
if re.search(pattern, cli_path):
return False
# On Windows, validate executable name additionally
if is_windows():
# Extract just the executable name
exe_name = os.path.basename(cli_path)
name_without_ext = os.path.splitext(exe_name)[0]
# Allow only alphanumeric, dots, hyphens, underscores in the name
if not name_without_ext or not all(
c.isalnum() or c in "._-" for c in name_without_ext
):
return False
# Check if path exists (if absolute)
if os.path.isabs(cli_path):
return os.path.isfile(cli_path)
return True
# ============================================================================
# Shell Execution
# ============================================================================
def requires_shell(command: str) -> bool:
"""
Check if a command requires shell execution on Windows.
Windows needs shell execution for .cmd and .bat files.
Args:
command: Command string to check
Returns:
True if shell execution is required
"""
if not is_windows():
return False
_, ext = os.path.splitext(command)
return ext.lower() in {".cmd", ".bat", ".ps1"}
def get_comspec_path() -> str:
"""
Get the path to cmd.exe on Windows.
Returns:
Path to cmd.exe or default location.
"""
if is_windows():
return os.environ.get(
"ComSpec",
os.path.join(
os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe"
),
)
return "/bin/sh"
def build_windows_command(cli_path: str, args: list[str]) -> list[str]:
"""
Build a command array for Windows execution.
Handles .cmd/.bat files that require shell execution.
Args:
cli_path: Path to the CLI executable
args: Command arguments
Returns:
Command array suitable for subprocess.run
"""
if is_windows() and cli_path.lower().endswith((".cmd", ".bat")):
# Use cmd.exe to execute .cmd/.bat files
cmd_exe = get_comspec_path()
# Properly escape arguments for Windows command line
escaped_args = subprocess.list2cmdline(args)
return [cmd_exe, "/d", "/s", "/c", f'"{cli_path}" {escaped_args}']
return [cli_path] + args
# ============================================================================
# Environment Variables
# ============================================================================
def get_env_var(name: str, default: str | None = None) -> str | None:
"""
Get environment variable value with case-insensitive support on Windows.
Args:
name: Environment variable name
default: Default value if not found
Returns:
Environment variable value or default
"""
if is_windows():
# Case-insensitive lookup on Windows
for key, value in os.environ.items():
if key.lower() == name.lower():
return value
return default
return os.environ.get(name, default)
# ============================================================================
# Platform Description
# ============================================================================
def get_platform_description() -> str:
"""
Get a human-readable platform description.
Returns:
String like "Windows (AMD64)" or "macOS (arm64)"
"""
os_name = {OS.WINDOWS: "Windows", OS.MACOS: "macOS", OS.LINUX: "Linux"}.get(
get_current_os(), platform.system()
)
arch = platform.machine()
return f"{os_name} ({arch})"
+12 -12
View File
@@ -43,7 +43,7 @@ def count_subtasks(spec_dir: Path) -> tuple[int, int]:
return 0, 0
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
total = 0
@@ -56,7 +56,7 @@ def count_subtasks(spec_dir: Path) -> tuple[int, int]:
completed += 1
return completed, total
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return 0, 0
@@ -81,7 +81,7 @@ def count_subtasks_detailed(spec_dir: Path) -> dict:
return result
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
for phase in plan.get("phases", []):
@@ -94,7 +94,7 @@ def count_subtasks_detailed(spec_dir: Path) -> dict:
result["pending"] += 1
return result
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return result
@@ -182,7 +182,7 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
# Phase summary
try:
with open(spec_dir / "implementation_plan.json") as f:
with open(spec_dir / "implementation_plan.json", encoding="utf-8") as f:
plan = json.load(f)
print("\nPhases:")
@@ -230,8 +230,8 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}"
)
except (OSError, json.JSONDecodeError):
pass
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
pass # Ignore corrupted/unreadable progress files
else:
print()
print_status("No implementation subtasks yet - planner needs to run", "pending")
@@ -302,7 +302,7 @@ def get_plan_summary(spec_dir: Path) -> dict:
}
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
summary = {
@@ -355,7 +355,7 @@ def get_plan_summary(spec_dir: Path) -> dict:
return summary
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return {
"workflow_type": None,
"total_phases": 0,
@@ -376,7 +376,7 @@ def get_current_phase(spec_dir: Path) -> dict | None:
return None
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
for phase in plan.get("phases", []):
@@ -396,7 +396,7 @@ def get_current_phase(spec_dir: Path) -> dict | None:
return None
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
@@ -470,7 +470,7 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
return None
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
+23 -12
View File
@@ -53,7 +53,7 @@ def _get_version() -> str:
if package_json.exists():
import json
with open(package_json) as f:
with open(package_json, encoding="utf-8") as f:
data = json.load(f)
return data.get("version", "0.0.0")
except Exception as e:
@@ -257,17 +257,28 @@ def init_sentry(
event_level=logging.ERROR, # Send ERROR and above as events
)
# Initialize Sentry
sentry_sdk.init(
dsn=dsn,
environment=environment,
release=f"auto-claude@{version}",
traces_sample_rate=traces_sample_rate,
before_send=_before_send,
integrations=[logging_integration],
# Don't send PII
send_default_pii=False,
)
# Initialize Sentry with exception handling for malformed DSN
try:
sentry_sdk.init(
dsn=dsn,
environment=environment,
release=f"auto-claude@{version}",
traces_sample_rate=traces_sample_rate,
before_send=_before_send,
integrations=[logging_integration],
# Don't send PII
send_default_pii=False,
)
except Exception as e:
# Handle malformed DSN (e.g., missing public key) gracefully
# This prevents crashes when SENTRY_DSN is misconfigured
logger.warning(
f"[Sentry] Failed to initialize - invalid DSN configuration: {e}"
)
logger.debug(
"[Sentry] DSN should be in format: https://[email protected]/PROJECT_ID"
)
return False
# Set component tag
sentry_sdk.set_tag("component", component)
+25 -9
View File
@@ -21,14 +21,21 @@ Example usage:
client = create_simple_client(agent_type="insights", cwd=project_dir)
"""
import logging
from pathlib import Path
from agents.tools_pkg import get_agent_config, get_default_thinking_level
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from core.auth import get_sdk_env_vars, require_auth_token
from core.client import find_claude_cli
from core.auth import (
get_sdk_env_vars,
require_auth_token,
validate_token_not_encrypted,
)
from core.platform import validate_cli_path
from phase_config import get_thinking_budget
logger = logging.getLogger(__name__)
def create_simple_client(
agent_type: str = "merge_resolver",
@@ -67,6 +74,12 @@ def create_simple_client(
"""
# Get authentication
oauth_token = require_auth_token()
# Validate token is not encrypted before passing to SDK
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
# If we still have an encrypted token here, it means decryption failed or was skipped
validate_token_not_encrypted(oauth_token)
import os
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
@@ -85,10 +98,8 @@ def create_simple_client(
thinking_level = get_default_thinking_level(agent_type)
max_thinking_tokens = get_thinking_budget(thinking_level)
# Find Claude CLI path (handles non-standard installations)
cli_path = find_claude_cli()
# Build options dict
# Note: SDK bundles its own CLI, so no cli_path detection needed
options_kwargs = {
"model": model,
"system_prompt": system_prompt,
@@ -96,11 +107,16 @@ def create_simple_client(
"max_turns": max_turns,
"cwd": str(cwd.resolve()) if cwd else None,
"env": sdk_env,
"max_thinking_tokens": max_thinking_tokens,
}
# Add CLI path if found
if cli_path:
options_kwargs["cli_path"] = cli_path
# Only add max_thinking_tokens if not None (Haiku doesn't support extended thinking)
if max_thinking_tokens is not None:
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
# Optional: Allow CLI path override via environment variable
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
if env_cli_path and validate_cli_path(env_cli_path):
options_kwargs["cli_path"] = env_cli_path
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
+23 -67
View File
@@ -17,7 +17,6 @@ This module has been refactored for better maintainability:
Public API is exported via workspace/__init__.py for backward compatibility.
"""
import subprocess
from pathlib import Path
# Import git command helper for centralized logging and allowlist compliance
@@ -188,11 +187,9 @@ def merge_existing_build(
# Detect current branch - this is where user wants changes merged
# Normal workflow: user is on their feature branch (e.g., version/2.5.5)
# and wants to merge the spec changes into it, then PR to main
current_branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
current_branch_result = run_git(
["rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
current_branch = (
current_branch_result.stdout.strip()
@@ -569,11 +566,9 @@ def _try_smart_merge_inner(
base_branch = git_conflicts.get("base_branch", "main")
# Get merge-base for diff
merge_base_result = subprocess.run(
["git", "merge-base", base_branch, spec_branch],
merge_base_result = run_git(
["merge-base", base_branch, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
)
merge_base = (
merge_base_result.stdout.strip()
@@ -655,22 +650,19 @@ def _try_smart_merge_inner(
# Stage all files in a single git add call for efficiency
if files_to_stage:
try:
subprocess.run(
["git", "add"] + files_to_stage,
cwd=project_dir,
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
add_result = run_git(
["add"] + files_to_stage,
cwd=project_dir,
)
if add_result.returncode != 0:
debug_warning(
MODULE, f"Failed to stage files for direct copy: {e.stderr}"
MODULE,
f"Failed to stage files for direct copy: {add_result.stderr}",
)
# Return failure - files were written but not staged
return {
"success": False,
"error": f"Failed to stage files: {e.stderr}",
"error": f"Failed to stage files: {add_result.stderr}",
"resolved_files": [],
}
@@ -1137,11 +1129,9 @@ def _resolve_git_conflicts_with_ai(
)
# Get merge-base commit
merge_base_result = subprocess.run(
["git", "merge-base", base_branch, spec_branch],
merge_base_result = run_git(
["merge-base", base_branch, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
)
merge_base = (
merge_base_result.stdout.strip() if merge_base_result.returncode == 0 else None
@@ -1194,11 +1184,7 @@ def _resolve_git_conflicts_with_ai(
)
if binary_content is not None:
target_path.write_bytes(binary_content)
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
run_git(["add", target_file_path], cwd=project_dir)
resolved_files.append(target_file_path)
debug(MODULE, f"Copied new binary file: {file_path}")
else:
@@ -1207,11 +1193,7 @@ def _resolve_git_conflicts_with_ai(
)
if content is not None:
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
run_git(["add", target_file_path], cwd=project_dir)
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
@@ -1351,9 +1333,7 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(merged_content, encoding="utf-8")
subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
)
run_git(["add", file_path], cwd=project_dir)
resolved_files.append(file_path)
# Show appropriate message based on merge type
if file_path in auto_merged_simple:
@@ -1372,11 +1352,7 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / file_path
if target_path.exists():
target_path.unlink()
subprocess.run(
["git", "add", file_path],
cwd=project_dir,
capture_output=True,
)
run_git(["add", file_path], cwd=project_dir)
resolved_files.append(file_path)
print(success(f" ✓ {file_path} (deleted)"))
except Exception as e:
@@ -1418,11 +1394,7 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / result.file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
["git", "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
run_git(["add", result.file_path], cwd=project_dir)
resolved_files.append(result.file_path)
if result.was_auto_merged:
@@ -1537,11 +1509,7 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / result.file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
["git", "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
run_git(["add", result.file_path], cwd=project_dir)
resolved_files.append(result.file_path)
if result.was_auto_merged:
@@ -1570,11 +1538,7 @@ def _resolve_git_conflicts_with_ai(
target_path = project_dir / target_file_path
if target_path.exists():
target_path.unlink()
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
run_git(["add", target_file_path], cwd=project_dir)
else:
# Modified without path change - simple copy
# Check if binary file to use correct read/write method
@@ -1587,11 +1551,7 @@ def _resolve_git_conflicts_with_ai(
)
if binary_content is not None:
target_path.write_bytes(binary_content)
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
run_git(["add", target_file_path], cwd=project_dir)
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
@@ -1604,11 +1564,7 @@ def _resolve_git_conflicts_with_ai(
)
if content is not None:
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
run_git(["add", target_file_path], cwd=project_dir)
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
+4 -4
View File
@@ -93,7 +93,7 @@ class MergeLock:
os.close(fd)
# Write our PID to the lock file
self.lock_file.write_text(str(os.getpid()))
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
self.acquired = True
return self
@@ -101,7 +101,7 @@ class MergeLock:
# Lock file exists - check if process is still running
if self.lock_file.exists():
try:
pid = int(self.lock_file.read_text().strip())
pid = int(self.lock_file.read_text(encoding="utf-8").strip())
# Import locally to avoid circular dependency
import os as _os
@@ -183,7 +183,7 @@ class SpecNumberLock:
os.close(fd)
# Write our PID to the lock file
self.lock_file.write_text(str(os.getpid()))
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
self.acquired = True
return self
@@ -191,7 +191,7 @@ class SpecNumberLock:
# Lock file exists - check if process is still running
if self.lock_file.exists():
try:
pid = int(self.lock_file.read_text().strip())
pid = int(self.lock_file.read_text(encoding="utf-8").strip())
import os as _os
try:
+116 -5
View File
@@ -7,7 +7,9 @@ Functions for setting up and initializing workspaces.
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
@@ -181,6 +183,106 @@ def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[s
return copied
def symlink_node_modules_to_worktree(
project_dir: Path, worktree_path: Path
) -> list[str]:
"""
Symlink node_modules directories from project root to worktree.
This ensures the worktree has access to dependencies for TypeScript checks
and other tooling without requiring a separate npm install.
Works with npm workspace hoisting where dependencies are hoisted to root
and workspace-specific dependencies remain in nested node_modules.
Args:
project_dir: The main project directory
worktree_path: Path to the worktree
Returns:
List of symlinked paths (relative to worktree)
"""
symlinked = []
# Node modules locations to symlink for TypeScript and tooling support.
# These are the standard locations for this monorepo structure.
#
# Design rationale:
# - Hardcoded paths are intentional for simplicity and reliability
# - Dynamic discovery (reading workspaces from package.json) would add complexity
# and potential failure points without significant benefit
# - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
# in root node_modules with workspace-specific deps in apps/frontend/node_modules
#
# To add new workspace locations:
# 1. Add (source_rel, target_rel) tuple below
# 2. Update the parallel TypeScript implementation in
# apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts
# 3. Update the pre-commit hook check in .husky/pre-commit if needed
node_modules_locations = [
("node_modules", "node_modules"),
("apps/frontend/node_modules", "apps/frontend/node_modules"),
]
for source_rel, target_rel in node_modules_locations:
source_path = project_dir / source_rel
target_path = worktree_path / target_rel
# Skip if source doesn't exist
if not source_path.exists():
debug(MODULE, f"Skipping {source_rel} - source does not exist")
continue
# Skip if target already exists (don't overwrite existing node_modules)
if target_path.exists():
debug(MODULE, f"Skipping {target_rel} - target already exists")
continue
# Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks)
if target_path.is_symlink():
debug(
MODULE,
f"Skipping {target_rel} - symlink already exists (possibly broken)",
)
continue
# Ensure parent directory exists
target_path.parent.mkdir(parents=True, exist_ok=True)
try:
if sys.platform == "win32":
# On Windows, use junctions instead of symlinks (no admin rights required)
# Junctions require absolute paths
result = subprocess.run(
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise OSError(result.stderr or "mklink /J failed")
else:
# On macOS/Linux, use relative symlinks for portability
relative_source = os.path.relpath(source_path, target_path.parent)
os.symlink(relative_source, target_path)
symlinked.append(target_rel)
debug(MODULE, f"Symlinked {target_rel} -> {source_path}")
except OSError as e:
# Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem)
# Log warning but don't fail - worktree is still usable, just without
# TypeScript checking
debug_warning(
MODULE,
f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.",
)
# Warn user - pre-commit hooks may fail without dependencies
print_status(
f"Warning: Could not link {target_rel} - TypeScript checks may fail",
"warning",
)
return symlinked
def copy_spec_to_worktree(
source_spec_dir: Path,
worktree_path: Path,
@@ -268,6 +370,14 @@ def setup_workspace(
f"Environment files copied: {', '.join(copied_env_files)}", "success"
)
# Symlink node_modules to worktree for TypeScript and tooling support
# This allows pre-commit hooks to run typecheck without npm install in worktree
symlinked_modules = symlink_node_modules_to_worktree(
project_dir, worktree_info.path
)
if symlinked_modules:
print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success")
# Copy security configuration files if they exist
# Note: Unlike env files, security files always overwrite to ensure
# the worktree uses the same security rules as the main project.
@@ -302,10 +412,10 @@ def setup_workspace(
if PROFILE_FILENAME in security_files_copied:
profile_path = worktree_info.path / PROFILE_FILENAME
try:
with open(profile_path) as f:
with open(profile_path, encoding="utf-8") as f:
profile_data = json.load(f)
profile_data["inherited_from"] = str(project_dir.resolve())
with open(profile_path, "w") as f:
with open(profile_path, "w", encoding="utf-8") as f:
json.dump(profile_data, f, indent=2)
debug(
MODULE, f"Marked security profile as inherited from {project_dir}"
@@ -364,7 +474,7 @@ def ensure_timeline_hook_installed(project_dir: Path) -> None:
# Handle worktrees (where .git is a file, not directory)
if git_dir.is_file():
content = git_dir.read_text().strip()
content = git_dir.read_text(encoding="utf-8").strip()
if content.startswith("gitdir:"):
git_dir = Path(content.split(":", 1)[1].strip())
else:
@@ -374,7 +484,7 @@ def ensure_timeline_hook_installed(project_dir: Path) -> None:
# Check if hook already installed
if hook_path.exists():
if "FileTimelineTracker" in hook_path.read_text():
if "FileTimelineTracker" in hook_path.read_text(encoding="utf-8"):
debug(MODULE, "FileTimelineTracker hook already installed")
return
@@ -412,7 +522,7 @@ def initialize_timeline_tracking(
if source_spec_dir:
plan_path = source_spec_dir / "implementation_plan.json"
if plan_path.exists():
with open(plan_path) as f:
with open(plan_path, encoding="utf-8") as f:
plan = json.load(f)
task_title = plan.get("title", spec_name)
task_intent = plan.get("description", "")
@@ -423,6 +533,7 @@ def initialize_timeline_tracking(
files_to_modify.extend(subtask.get("files", []))
# Get the current branch point commit
# Note: run_git() already handles capture_output and encoding internally
result = run_git(
["rev-parse", "HEAD"],
cwd=project_dir,
+33 -4
View File
@@ -26,7 +26,8 @@ from datetime import datetime
from pathlib import Path
from typing import TypedDict, TypeVar
from core.git_executable import get_git_executable, run_git
from core.gh_executable import get_gh_executable, invalidate_gh_cache
from core.git_executable import get_git_executable, get_isolated_git_env, run_git
from debug import debug_warning
T = TypeVar("T")
@@ -883,6 +884,7 @@ class WorktreeManager:
encoding="utf-8",
errors="replace",
timeout=self.GIT_PUSH_TIMEOUT,
env=get_isolated_git_env(),
)
if result.returncode == 0:
@@ -959,9 +961,17 @@ class WorktreeManager:
# Get PR body from spec.md if available
pr_body = self._extract_spec_summary(spec_name)
# Find gh executable before attempting PR creation
gh_executable = get_gh_executable()
if not gh_executable:
return PullRequestResult(
success=False,
error="GitHub CLI (gh) not found. Install from https://cli.github.com/",
)
# Build gh pr create command
gh_args = [
"gh",
gh_executable,
"pr",
"create",
"--base",
@@ -993,6 +1003,7 @@ class WorktreeManager:
encoding="utf-8",
errors="replace",
timeout=self.GH_CLI_TIMEOUT,
env=get_isolated_git_env(),
)
# Check for "already exists" case (success, no retry needed)
@@ -1063,7 +1074,8 @@ class WorktreeManager:
)
except FileNotFoundError:
# gh CLI not installed
# Cached gh path became invalid - clear cache so next call re-discovers
invalidate_gh_cache()
return PullRequestResult(
success=False,
error="gh CLI not found. Install from https://cli.github.com/",
@@ -1121,15 +1133,30 @@ class WorktreeManager:
if not info:
return None
gh_executable = get_gh_executable()
if not gh_executable:
# gh CLI not found - return None and let caller handle it
return None
try:
result = subprocess.run(
["gh", "pr", "view", info.branch, "--json", "url", "--jq", ".url"],
[
gh_executable,
"pr",
"view",
info.branch,
"--json",
"url",
"--jq",
".url",
],
cwd=info.path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.GH_QUERY_TIMEOUT,
env=get_isolated_git_env(),
)
if result.returncode == 0:
return result.stdout.strip()
@@ -1141,6 +1168,8 @@ class WorktreeManager:
# Silently ignore errors when fetching existing PR URL - this is a best-effort
# lookup that may fail due to network issues, missing gh CLI, or auth problems.
# Returning None allows the caller to handle missing URLs gracefully.
if isinstance(e, FileNotFoundError):
invalidate_gh_cache()
debug_warning("worktree", f"Could not get existing PR URL: {e}")
return None
+4 -4
View File
@@ -51,7 +51,7 @@ class ProjectAnalyzer:
project_index_path = self.project_dir / ".auto-claude" / "project_index.json"
if project_index_path.exists():
try:
with open(project_index_path) as f:
with open(project_index_path, encoding="utf-8") as f:
index = json.load(f)
# Extract tech stack from services
for service_name, service_info in index.get("services", {}).items():
@@ -70,7 +70,7 @@ class ProjectAnalyzer:
)
if roadmap_path.exists():
try:
with open(roadmap_path) as f:
with open(roadmap_path, encoding="utf-8") as f:
roadmap = json.load(f)
# Extract planned features
for feature in roadmap.get("features", []):
@@ -87,7 +87,7 @@ class ProjectAnalyzer:
)
if discovery_path.exists() and not context["target_audience"]:
try:
with open(discovery_path) as f:
with open(discovery_path, encoding="utf-8") as f:
discovery = json.load(f)
audience = discovery.get("target_audience", {})
context["target_audience"] = audience.get("primary_persona")
@@ -109,7 +109,7 @@ class ProjectAnalyzer:
spec_file = spec_dir / "spec.md"
if spec_file.exists():
# Extract title from spec
content = spec_file.read_text()
content = spec_file.read_text(encoding="utf-8")
lines = content.split("\n")
for line in lines:
if line.startswith("# "):
+4 -4
View File
@@ -39,7 +39,7 @@ class IdeationFormatter:
existing_session = None
if append and ideation_file.exists():
try:
with open(ideation_file) as f:
with open(ideation_file, encoding="utf-8") as f:
existing_session = json.load(f)
existing_ideas = existing_session.get("ideas", [])
print_status(
@@ -56,7 +56,7 @@ class IdeationFormatter:
type_file = self.output_dir / f"{ideation_type}_ideas.json"
if type_file.exists():
try:
with open(type_file) as f:
with open(type_file, encoding="utf-8") as f:
data = json.load(f)
ideas = data.get(ideation_type, [])
new_ideas.extend(ideas)
@@ -123,7 +123,7 @@ class IdeationFormatter:
ideation_session["summary"]["by_status"].get(idea_status, 0) + 1
)
with open(ideation_file, "w") as f:
with open(ideation_file, "w", encoding="utf-8") as f:
json.dump(ideation_session, f, indent=2)
action = "Updated" if append else "Created"
@@ -139,7 +139,7 @@ class IdeationFormatter:
context_data = {}
if context_file.exists():
try:
with open(context_file) as f:
with open(context_file, encoding="utf-8") as f:
context_data = json.load(f)
except json.JSONDecodeError:
pass
+1 -1
View File
@@ -80,7 +80,7 @@ class IdeationGenerator:
return False, f"Prompt not found: {prompt_path}"
# Load prompt
prompt = prompt_path.read_text()
prompt = prompt_path.read_text(encoding="utf-8")
# Add context
prompt += f"\n\n---\n\n**Output Directory**: {self.output_dir}\n"
+7 -7
View File
@@ -85,7 +85,7 @@ class PhaseExecutor:
if not is_graphiti_enabled():
print_status("Graphiti not enabled, skipping graph hints", "info")
with open(hints_file, "w") as f:
with open(hints_file, "w", encoding="utf-8") as f:
json.dump(
{
"enabled": False,
@@ -131,7 +131,7 @@ class PhaseExecutor:
total_hints += len(result)
# Save hints
with open(hints_file, "w") as f:
with open(hints_file, "w", encoding="utf-8") as f:
json.dump(
{
"enabled": True,
@@ -178,11 +178,11 @@ class PhaseExecutor:
graph_hints = {}
if hints_file.exists():
try:
with open(hints_file) as f:
with open(hints_file, encoding="utf-8") as f:
hints_data = json.load(f)
graph_hints = hints_data.get("hints_by_type", {})
except (OSError, json.JSONDecodeError):
pass
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
pass # Use empty hints if file is corrupted/unreadable
# Write context file
context_data = {
@@ -200,7 +200,7 @@ class PhaseExecutor:
"created_at": datetime.now().isoformat(),
}
with open(context_file, "w") as f:
with open(context_file, "w", encoding="utf-8") as f:
json.dump(context_data, f, indent=2)
print_status("Created ideation_context.json", "success")
@@ -252,7 +252,7 @@ class PhaseExecutor:
if output_file.exists() and not self.refresh:
# Load and validate existing ideas - only skip if we have valid ideas
try:
with open(output_file) as f:
with open(output_file, encoding="utf-8") as f:
data = json.load(f)
count = len(data.get(ideation_type, []))
+2 -2
View File
@@ -48,7 +48,7 @@ class IdeaPrioritizer:
}
try:
content = output_file.read_text()
content = output_file.read_text(encoding="utf-8")
data = json.loads(content)
debug_verbose(
"ideation_prioritizer",
@@ -102,7 +102,7 @@ class IdeaPrioritizer:
return {
"success": False,
"error": f"Invalid JSON: {e}",
"current_content": output_file.read_text()
"current_content": output_file.read_text(encoding="utf-8")
if output_file.exists()
else "",
"count": 0,
+1 -1
View File
@@ -232,7 +232,7 @@ class IdeationOrchestrator:
"""Print summary of ideation generation results."""
ideation_file = self.output_dir / "ideation.json"
if ideation_file.exists():
with open(ideation_file) as f:
with open(ideation_file, encoding="utf-8") as f:
ideation = json.load(f)
ideas = ideation.get("ideas", [])
+106 -10
View File
@@ -4,8 +4,15 @@ Auto Claude project initialization utilities.
Handles first-time setup of .auto-claude directory and ensures proper gitignore configuration.
"""
import logging
import os
import subprocess
from pathlib import Path
from core.git_executable import get_git_executable
logger = logging.getLogger(__name__)
# All entries that should be added to .gitignore for auto-claude projects
AUTO_CLAUDE_GITIGNORE_ENTRIES = [
".auto-claude/",
@@ -50,7 +57,7 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
# Check if .gitignore exists and if entry is already present
if gitignore_path.exists():
content = gitignore_path.read_text()
content = gitignore_path.read_text(encoding="utf-8")
lines = content.splitlines()
if _entry_exists_in_gitignore(lines, entry):
@@ -65,18 +72,94 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
content += "\n# Auto Claude data directory\n"
content += entry + "\n"
gitignore_path.write_text(content)
gitignore_path.write_text(content, encoding="utf-8")
return True
else:
# Create new .gitignore with the entry
content = "# Auto Claude data directory\n"
content += entry + "\n"
gitignore_path.write_text(content)
gitignore_path.write_text(content, encoding="utf-8")
return True
def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
def _is_git_repo(project_dir: Path) -> bool:
"""Check if the directory is a git repository."""
try:
result = subprocess.run(
[get_git_executable(), "rev-parse", "--is-inside-work-tree"],
cwd=project_dir,
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, Exception) as e:
logger.debug("Git repo check failed: %s", e)
return False
def _commit_gitignore(project_dir: Path) -> bool:
"""
Commit .gitignore changes with a standard message.
FIX (#1087): Auto-commit .gitignore changes to prevent merge failures.
Without this, merging tasks fails with "local changes would be overwritten".
Args:
project_dir: The project root directory
Returns:
True if commit succeeded, False otherwise
"""
if not _is_git_repo(project_dir):
return False
try:
# Use LC_ALL=C to ensure English git output for reliable parsing
git_env = {**os.environ, "LC_ALL": "C"}
# Stage .gitignore
result = subprocess.run(
[get_git_executable(), "add", ".gitignore"],
cwd=project_dir,
capture_output=True,
text=True,
timeout=30,
env=git_env,
)
if result.returncode != 0:
return False
# Commit with standard message - explicitly specify .gitignore to avoid
# committing other staged files the user may have
result = subprocess.run(
[
get_git_executable(),
"commit",
".gitignore",
"-m",
"chore: add auto-claude entries to .gitignore",
],
cwd=project_dir,
capture_output=True,
text=True,
timeout=30,
env=git_env,
)
# Return True even if commit "fails" due to nothing to commit
# Check both stdout and stderr as message location varies by git version
combined_output = result.stdout + result.stderr
return result.returncode == 0 or "nothing to commit" in combined_output
except (subprocess.TimeoutExpired, Exception) as e:
logger.debug("Git commit failed: %s", e)
return False
def ensure_all_gitignore_entries(
project_dir: Path, auto_commit: bool = False
) -> list[str]:
"""
Ensure all auto-claude related entries exist in the project's .gitignore file.
@@ -84,6 +167,7 @@ def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
Args:
project_dir: The project root directory
auto_commit: If True, automatically commit the .gitignore changes
Returns:
List of entries that were added (empty if all already existed)
@@ -93,7 +177,7 @@ def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
# Read existing content or start fresh
if gitignore_path.exists():
content = gitignore_path.read_text()
content = gitignore_path.read_text(encoding="utf-8")
lines = content.splitlines()
else:
content = ""
@@ -119,7 +203,17 @@ def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
content += entry + "\n"
added_entries.append(entry)
gitignore_path.write_text(content)
gitignore_path.write_text(content, encoding="utf-8")
# Auto-commit if requested and entries were added
if auto_commit and added_entries:
if not _commit_gitignore(project_dir):
logger.warning(
"Failed to auto-commit .gitignore changes in %s. "
"Manual commit may be required to avoid merge conflicts.",
project_dir,
)
return added_entries
@@ -143,16 +237,17 @@ def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]:
auto_claude_dir.mkdir(parents=True, exist_ok=True)
# Ensure all auto-claude entries are in .gitignore (only on first creation)
# FIX (#1087): Auto-commit the changes to prevent merge failures
gitignore_updated = False
if dir_created:
added = ensure_all_gitignore_entries(project_dir)
added = ensure_all_gitignore_entries(project_dir, auto_commit=True)
gitignore_updated = len(added) > 0
else:
# Even if dir exists, check gitignore on first run
# Use a marker file to track if we've already checked
marker = auto_claude_dir / ".gitignore_checked"
if not marker.exists():
added = ensure_all_gitignore_entries(project_dir)
added = ensure_all_gitignore_entries(project_dir, auto_commit=True)
gitignore_updated = len(added) > 0
marker.touch()
@@ -185,6 +280,7 @@ def repair_gitignore(project_dir: Path) -> list[str]:
or when gitignore entries were manually removed.
Also resets the .gitignore_checked marker to allow future updates.
Changes are automatically committed if the project is a git repository.
Args:
project_dir: The project root directory
@@ -200,8 +296,8 @@ def repair_gitignore(project_dir: Path) -> list[str]:
if marker.exists():
marker.unlink()
# Add all missing entries
added = ensure_all_gitignore_entries(project_dir)
# Add all missing entries and auto-commit
added = ensure_all_gitignore_entries(project_dir, auto_commit=True)
# Re-create the marker
if auto_claude_dir.exists():
+5 -5
View File
@@ -146,7 +146,7 @@ class GraphitiConfig:
# OpenRouter settings (multi-provider aggregator)
openrouter_api_key: str = ""
openrouter_base_url: str = "https://openrouter.ai/api/v1"
openrouter_llm_model: str = "anthropic/claude-3.5-sonnet"
openrouter_llm_model: str = "anthropic/claude-sonnet-4"
openrouter_embedding_model: str = "openai/text-embedding-3-small"
# Ollama settings (local)
@@ -210,7 +210,7 @@ class GraphitiConfig:
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
)
openrouter_llm_model = os.environ.get(
"OPENROUTER_LLM_MODEL", "anthropic/claude-3.5-sonnet"
"OPENROUTER_LLM_MODEL", "anthropic/claude-sonnet-4"
)
openrouter_embedding_model = os.environ.get(
"OPENROUTER_EMBEDDING_MODEL", "openai/text-embedding-3-small"
@@ -507,7 +507,7 @@ class GraphitiState:
def save(self, spec_dir: Path) -> None:
"""Save state to the spec directory."""
marker_file = spec_dir / GRAPHITI_STATE_MARKER
with open(marker_file, "w") as f:
with open(marker_file, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
@@ -518,9 +518,9 @@ class GraphitiState:
return None
try:
with open(marker_file) as f:
with open(marker_file, encoding="utf-8") as f:
return cls.from_dict(json.load(f))
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
def record_error(self, error_msg: str) -> None:
@@ -35,7 +35,7 @@ def create_openrouter_llm_client(config: "GraphitiConfig") -> Any:
>>> from auto_claude.integrations.graphiti.config import GraphitiConfig
>>> config = GraphitiConfig(
... openrouter_api_key="sk-or-...",
... openrouter_llm_model="anthropic/claude-3.5-sonnet"
... openrouter_llm_model="anthropic/claude-sonnet-4"
... )
>>> client = create_openrouter_llm_client(config)
"""
+3 -3
View File
@@ -126,7 +126,7 @@ class LinearProjectState:
def save(self, spec_dir: Path) -> None:
"""Save state to the spec directory."""
marker_file = spec_dir / LINEAR_PROJECT_MARKER
with open(marker_file, "w") as f:
with open(marker_file, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
@@ -137,9 +137,9 @@ class LinearProjectState:
return None
try:
with open(marker_file) as f:
with open(marker_file, encoding="utf-8") as f:
return cls.from_dict(json.load(f))
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
@@ -158,9 +158,9 @@ class LinearManager:
return None
try:
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
def get_subtasks_for_sync(self) -> list[dict]:
+3 -3
View File
@@ -81,7 +81,7 @@ class LinearTaskState:
def save(self, spec_dir: Path) -> None:
"""Save state to the spec directory."""
state_file = spec_dir / LINEAR_TASK_FILE
with open(state_file, "w") as f:
with open(state_file, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2)
@classmethod
@@ -92,9 +92,9 @@ class LinearTaskState:
return None
try:
with open(state_file) as f:
with open(state_file, encoding="utf-8") as f:
return cls.from_dict(json.load(f))
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
+5 -5
View File
@@ -38,9 +38,9 @@ def update_codebase_map(spec_dir: Path, discoveries: dict[str, str]) -> None:
# Load existing map or create new
if map_file.exists():
try:
with open(map_file) as f:
with open(map_file, encoding="utf-8") as f:
codebase_map = json.load(f)
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
codebase_map = {}
else:
codebase_map = {}
@@ -58,7 +58,7 @@ def update_codebase_map(spec_dir: Path, discoveries: dict[str, str]) -> None:
)
# Write back
with open(map_file, "w") as f:
with open(map_file, "w", encoding="utf-8") as f:
json.dump(codebase_map, f, indent=2, sort_keys=True)
# Also save to Graphiti if enabled
@@ -90,12 +90,12 @@ def load_codebase_map(spec_dir: Path) -> dict[str, str]:
return {}
try:
with open(map_file) as f:
with open(map_file, encoding="utf-8") as f:
codebase_map = json.load(f)
# Remove metadata before returning
codebase_map.pop("_metadata", None)
return codebase_map
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return {}
+6 -6
View File
@@ -36,7 +36,7 @@ def append_gotcha(spec_dir: Path, gotcha: str) -> None:
# Load existing gotchas
existing_gotchas = set()
if gotchas_file.exists():
content = gotchas_file.read_text()
content = gotchas_file.read_text(encoding="utf-8")
# Extract bullet points
for line in content.split("\n"):
line = line.strip()
@@ -47,7 +47,7 @@ def append_gotcha(spec_dir: Path, gotcha: str) -> None:
gotcha_stripped = gotcha.strip()
if gotcha_stripped and gotcha_stripped not in existing_gotchas:
# Append to file
with open(gotchas_file, "a") as f:
with open(gotchas_file, "a", encoding="utf-8") as f:
if gotchas_file.stat().st_size == 0:
# First entry - add header
f.write("# Gotchas and Pitfalls\n\n")
@@ -80,7 +80,7 @@ def load_gotchas(spec_dir: Path) -> list[str]:
if not gotchas_file.exists():
return []
content = gotchas_file.read_text()
content = gotchas_file.read_text(encoding="utf-8")
gotchas = []
for line in content.split("\n"):
@@ -112,7 +112,7 @@ def append_pattern(spec_dir: Path, pattern: str) -> None:
# Load existing patterns
existing_patterns = set()
if patterns_file.exists():
content = patterns_file.read_text()
content = patterns_file.read_text(encoding="utf-8")
# Extract bullet points
for line in content.split("\n"):
line = line.strip()
@@ -123,7 +123,7 @@ def append_pattern(spec_dir: Path, pattern: str) -> None:
pattern_stripped = pattern.strip()
if pattern_stripped and pattern_stripped not in existing_patterns:
# Append to file
with open(patterns_file, "a") as f:
with open(patterns_file, "a", encoding="utf-8") as f:
if patterns_file.stat().st_size == 0:
# First entry - add header
f.write("# Code Patterns\n\n")
@@ -156,7 +156,7 @@ def load_patterns(spec_dir: Path) -> list[str]:
if not patterns_file.exists():
return []
content = patterns_file.read_text()
content = patterns_file.read_text(encoding="utf-8")
patterns = []
for line in content.split("\n"):
+3 -3
View File
@@ -76,7 +76,7 @@ def save_session_insights(
}
# Write to file (always use file-based storage)
with open(session_file, "w") as f:
with open(session_file, "w", encoding="utf-8") as f:
json.dump(session_data, f, indent=2)
# Also save to Graphiti if enabled (non-blocking, errors logged but not raised)
@@ -110,9 +110,9 @@ def load_all_insights(spec_dir: Path) -> list[dict[str, Any]]:
insights = []
for session_file in session_files:
try:
with open(session_file) as f:
with open(session_file, encoding="utf-8") as f:
insights.append(json.load(f))
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
# Skip corrupted files
continue
+2 -2
View File
@@ -61,7 +61,7 @@ class EvolutionStorage:
return {}
try:
with open(self.evolution_file) as f:
with open(self.evolution_file, encoding="utf-8") as f:
data = json.load(f)
evolutions = {}
@@ -88,7 +88,7 @@ class EvolutionStorage:
for file_path, evolution in evolutions.items()
}
with open(self.evolution_file, "w") as f:
with open(self.evolution_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
logger.debug(f"Saved evolution data for {len(evolutions)} files")
+17 -3
View File
@@ -83,10 +83,16 @@ def apply_single_task_changes(
# Addition - need to determine where to add
if change.change_type == ChangeType.ADD_IMPORT:
# Add import at top
# Content is already normalized to LF, so only check for \n
has_trailing_newline = content.endswith("\n")
lines = content.splitlines()
import_end = find_import_end(lines, file_path)
lines.insert(import_end, change.content_after)
# Strip trailing newline from content_after to prevent double newlines
# (content_after may include newline from diff generation)
lines.insert(import_end, change.content_after.rstrip("\n\r"))
content = line_ending.join(lines)
if has_trailing_newline:
content += line_ending
elif change.change_type == ChangeType.ADD_FUNCTION:
# Add function at end (before exports)
content += f"{line_ending}{line_ending}{change.content_after}"
@@ -149,13 +155,21 @@ def combine_non_conflicting_changes(
# Add imports
if imports:
# Content is already normalized to LF, so only check for \n
has_trailing_newline = content.endswith("\n")
lines = content.splitlines()
import_end = find_import_end(lines, file_path)
for imp in imports:
if imp.content_after and imp.content_after not in content:
lines.insert(import_end, imp.content_after)
# Strip trailing newline from content_after to prevent double newlines
import_content = (
imp.content_after.rstrip("\n\r") if imp.content_after else ""
)
if import_content and import_content not in content:
lines.insert(import_end, import_content)
import_end += 1
content = line_ending.join(lines)
if has_trailing_newline:
content += line_ending
# Apply modifications
for mod in modifications:
+6 -6
View File
@@ -75,7 +75,7 @@ def install_hook(project_path: Path) -> bool:
# Handle worktrees (where .git is a file, not directory)
if git_dir.is_file():
# Read the gitdir from the file
content = git_dir.read_text().strip()
content = git_dir.read_text(encoding="utf-8").strip()
if content.startswith("gitdir:"):
git_dir = Path(content.split(":", 1)[1].strip())
else:
@@ -93,7 +93,7 @@ def install_hook(project_path: Path) -> bool:
# Check if hook already exists
if hook_path.exists():
existing = hook_path.read_text()
existing = hook_path.read_text(encoding="utf-8")
if "FileTimelineTracker" in existing:
print(f"Hook already installed at {hook_path}")
return True
@@ -104,13 +104,13 @@ def install_hook(project_path: Path) -> bool:
print(f"Backed up existing hook to {backup_path}")
# Append our hook to existing
with open(hook_path, "a") as f:
with open(hook_path, "a", encoding="utf-8") as f:
f.write("\n\n# FileTimelineTracker integration\n")
f.write(HOOK_SCRIPT.split("#!/bin/bash", 1)[1]) # Skip shebang
print(f"Appended FileTimelineTracker hook to {hook_path}")
else:
# Write new hook
hook_path.write_text(HOOK_SCRIPT)
hook_path.write_text(HOOK_SCRIPT, encoding="utf-8")
print(f"Created new hook at {hook_path}")
# Make executable
@@ -127,7 +127,7 @@ def uninstall_hook(project_path: Path) -> bool:
git_dir = project_path / ".git"
if git_dir.is_file():
content = git_dir.read_text().strip()
content = git_dir.read_text(encoding="utf-8").strip()
if content.startswith("gitdir:"):
git_dir = Path(content.split(":", 1)[1].strip())
@@ -137,7 +137,7 @@ def uninstall_hook(project_path: Path) -> bool:
print("No hook to uninstall")
return True
content = hook_path.read_text()
content = hook_path.read_text(encoding="utf-8")
if "FileTimelineTracker" not in content:
print("Hook does not contain FileTimelineTracker integration")
return True
+1 -1
View File
@@ -108,5 +108,5 @@ class MergeReport:
def save(self, path: Path) -> None:
"""Save report to JSON file."""
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2)
+15 -7
View File
@@ -17,6 +17,8 @@ import logging
import subprocess
from pathlib import Path
from core.git_executable import get_isolated_git_env
logger = logging.getLogger(__name__)
# Import debug utilities
@@ -62,6 +64,7 @@ class TimelineGitHelper:
capture_output=True,
text=True,
check=True,
env=get_isolated_git_env(),
)
return result.stdout.strip()
except subprocess.CalledProcessError:
@@ -86,6 +89,7 @@ class TimelineGitHelper:
cwd=self.project_path,
capture_output=True,
text=True,
env=get_isolated_git_env(),
)
if result.returncode == 0:
return result.stdout
@@ -117,6 +121,7 @@ class TimelineGitHelper:
capture_output=True,
text=True,
check=True,
env=get_isolated_git_env(),
)
return [f for f in result.stdout.strip().split("\n") if f]
except subprocess.CalledProcessError:
@@ -133,33 +138,34 @@ class TimelineGitHelper:
Dictionary with keys: message, author, diff_summary
"""
info = {}
env = get_isolated_git_env()
try:
# Get commit message
result = subprocess.run(
["git", "log", "-1", "--format=%s", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
env=env,
)
if result.returncode == 0:
info["message"] = result.stdout.strip()
# Get author
result = subprocess.run(
["git", "log", "-1", "--format=%an", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
env=env,
)
if result.returncode == 0:
info["author"] = result.stdout.strip()
# Get diff stat
result = subprocess.run(
["git", "diff-tree", "--stat", "--no-commit-id", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
env=env,
)
if result.returncode == 0:
info["diff_summary"] = (
@@ -226,6 +232,7 @@ class TimelineGitHelper:
cwd=worktree_path,
capture_output=True,
text=True,
env=get_isolated_git_env(),
)
if result.returncode != 0:
@@ -259,6 +266,7 @@ class TimelineGitHelper:
cwd=worktree_path,
capture_output=True,
text=True,
env=get_isolated_git_env(),
)
if result.returncode != 0:
@@ -284,24 +292,23 @@ class TimelineGitHelper:
Returns:
The detected target branch name, defaults to 'main' if detection fails
"""
# Try to get the upstream tracking branch
env = get_isolated_git_env()
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=worktree_path,
capture_output=True,
text=True,
env=env,
)
if result.returncode == 0 and result.stdout.strip():
upstream = result.stdout.strip()
# Extract branch name from origin/branch format
if "/" in upstream:
return upstream.split("/", 1)[1]
return upstream
except Exception:
pass
# Try common branch names and find which one has a valid merge-base
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
@@ -309,13 +316,13 @@ class TimelineGitHelper:
cwd=worktree_path,
capture_output=True,
text=True,
env=env,
)
if result.returncode == 0:
return branch
except Exception:
continue
# Default to main
return "main"
def count_commits_between(self, from_commit: str, to_commit: str) -> int:
@@ -335,6 +342,7 @@ class TimelineGitHelper:
cwd=self.project_path,
capture_output=True,
text=True,
env=get_isolated_git_env(),
)
if result.returncode == 0:
+4 -4
View File
@@ -71,13 +71,13 @@ class TimelinePersistence:
return timelines
try:
with open(index_path) as f:
with open(index_path, encoding="utf-8") as f:
index = json.load(f)
for file_path in index.get("files", []):
timeline_file = self._get_timeline_file_path(file_path)
if timeline_file.exists():
with open(timeline_file) as f:
with open(timeline_file, encoding="utf-8") as f:
data = json.load(f)
timelines[file_path] = FileTimeline.from_dict(data)
@@ -101,7 +101,7 @@ class TimelinePersistence:
timeline_file = self._get_timeline_file_path(file_path)
timeline_file.parent.mkdir(parents=True, exist_ok=True)
with open(timeline_file, "w") as f:
with open(timeline_file, "w", encoding="utf-8") as f:
json.dump(timeline.to_dict(), f, indent=2)
except Exception as e:
@@ -119,7 +119,7 @@ class TimelinePersistence:
"files": file_paths,
"last_updated": datetime.now().isoformat(),
}
with open(index_path, "w") as f:
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2)
def _get_timeline_file_path(self, file_path: str) -> Path:
+2 -2
View File
@@ -25,7 +25,7 @@ THINKING_BUDGET_MAP: dict[str, int | None] = {
"low": 1024,
"medium": 4096, # Moderate analysis
"high": 16384, # Deep thinking for QA review
"ultrathink": 65536, # Maximum reasoning depth
"ultrathink": 63999, # Maximum reasoning depth (API requires max_tokens >= budget + 1, so 63999 + 1 = 64000 limit)
}
# Spec runner phase-specific thinking levels
@@ -164,7 +164,7 @@ def load_task_metadata(spec_dir: Path) -> TaskMetadataConfig | None:
return None
try:
with open(metadata_path) as f:
with open(metadata_path, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
+15 -7
View File
@@ -41,21 +41,29 @@ class ContextLoader:
"""Load all context files from spec directory."""
# Read spec.md
spec_file = self.spec_dir / "spec.md"
spec_content = spec_file.read_text() if spec_file.exists() else ""
spec_content = (
spec_file.read_text(encoding="utf-8") if spec_file.exists() else ""
)
# Read project_index.json
index_file = self.spec_dir / "project_index.json"
project_index = {}
if index_file.exists():
with open(index_file) as f:
project_index = json.load(f)
try:
with open(index_file, encoding="utf-8") as f:
project_index = json.load(f)
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
pass # Use empty dict on error
# Read context.json
context_file = self.spec_dir / "context.json"
task_context = {}
if context_file.exists():
with open(context_file) as f:
task_context = json.load(f)
try:
with open(context_file, encoding="utf-8") as f:
task_context = json.load(f)
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
pass # Use empty dict on error
# Determine services involved
services = task_context.get("scoped_services", [])
@@ -89,7 +97,7 @@ class ContextLoader:
requirements_file = self.spec_dir / "requirements.json"
if requirements_file.exists():
try:
with open(requirements_file) as f:
with open(requirements_file, encoding="utf-8") as f:
requirements = json.load(f)
declared_type = _normalize_workflow_type(
requirements.get("workflow_type", "")
@@ -103,7 +111,7 @@ class ContextLoader:
assessment_file = self.spec_dir / "complexity_assessment.json"
if assessment_file.exists():
try:
with open(assessment_file) as f:
with open(assessment_file, encoding="utf-8") as f:
assessment = json.load(f)
declared_type = _normalize_workflow_type(
assessment.get("workflow_type", "")
+1 -1
View File
@@ -52,7 +52,7 @@ def main():
print(f"Error: No implementation_plan.json found in {spec_dir}")
sys.exit(1)
with open(plan_file) as f:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
# Find first pending subtask
+4 -4
View File
@@ -33,7 +33,7 @@ class MemoryLoader:
return []
gotchas = []
content = self.gotchas_file.read_text()
content = self.gotchas_file.read_text(encoding="utf-8")
# Parse markdown list items
for line in content.split("\n"):
@@ -56,7 +56,7 @@ class MemoryLoader:
return []
patterns = []
content = self.patterns_file.read_text()
content = self.patterns_file.read_text(encoding="utf-8")
# Parse markdown sections
current_pattern = None
@@ -89,8 +89,8 @@ class MemoryLoader:
return []
try:
with open(self.history_file) as f:
with open(self.history_file, encoding="utf-8") as f:
history = json.load(f)
return history.get("attempts", [])
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return []
+2 -2
View File
@@ -69,7 +69,7 @@ class ProjectAnalyzer:
return None
try:
with open(profile_path) as f:
with open(profile_path, encoding="utf-8") as f:
data = json.load(f)
return SecurityProfile.from_dict(data)
except (OSError, json.JSONDecodeError, KeyError):
@@ -80,7 +80,7 @@ class ProjectAnalyzer:
profile_path = self.get_profile_path()
profile_path.parent.mkdir(parents=True, exist_ok=True)
with open(profile_path, "w") as f:
with open(profile_path, "w", encoding="utf-8") as f:
json.dump(profile.to_dict(), f, indent=2)
def compute_project_hash(self) -> str:
+2 -2
View File
@@ -38,7 +38,7 @@ class ConfigParser:
def read_json(self, filename: str) -> dict | None:
"""Read a JSON file from project root."""
try:
with open(self.project_dir / filename) as f:
with open(self.project_dir / filename, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
@@ -59,7 +59,7 @@ class ConfigParser:
def read_text(self, filename: str) -> str | None:
"""Read a text file from project root."""
try:
with open(self.project_dir / filename) as f:
with open(self.project_dir / filename, encoding="utf-8") as f:
return f.read()
except (OSError, FileNotFoundError):
return None
+1 -1
View File
@@ -248,7 +248,7 @@ class StackDetector:
"**/*.yaml"
) + self.parser.glob_files("**/*.yml"):
try:
with open(yaml_file) as f:
with open(yaml_file, encoding="utf-8") as f:
content = f.read()
if "apiVersion:" in content and "kind:" in content:
self.stack.infrastructure.append("kubernetes")
+4 -4
View File
@@ -942,13 +942,13 @@ if insights["discoveries"]["patterns_found"]:
# Load existing patterns
existing_patterns = set()
if patterns_file.exists():
content = patterns_file.read_text()
content = patterns_file.read_text(encoding="utf-8")
for line in content.split("\n"):
if line.strip().startswith("- "):
existing_patterns.add(line.strip()[2:])
# Add new patterns
with open(patterns_file, "a") as f:
with open(patterns_file, "a", encoding="utf-8") as f:
if patterns_file.stat().st_size == 0:
f.write("# Code Patterns\n\n")
f.write("Established patterns to follow in this codebase:\n\n")
@@ -965,13 +965,13 @@ if insights["discoveries"]["gotchas_encountered"]:
# Load existing gotchas
existing_gotchas = set()
if gotchas_file.exists():
content = gotchas_file.read_text()
content = gotchas_file.read_text(encoding="utf-8")
for line in content.split("\n"):
if line.strip().startswith("- "):
existing_gotchas.add(line.strip()[2:])
# Add new gotchas
with open(gotchas_file, "a") as f:
with open(gotchas_file, "a", encoding="utf-8") as f:
if gotchas_file.stat().st_size == 0:
f.write("# Gotchas and Pitfalls\n\n")
f.write("Things to watch out for in this codebase:\n\n")

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