Compare commits
183 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdca9af3b8 | |||
| 06fc5dab10 | |||
| a960f00307 | |||
| a05216590b | |||
| 57fcc2403b | |||
| c93fe96ee2 | |||
| 6ee5a731f4 | |||
| f6601efc8a | |||
| a03fa8bc80 | |||
| 50f739dc16 | |||
| 14238788c9 | |||
| 39a08f6117 | |||
| 87e12cf627 | |||
| 4c8dfcafa7 | |||
| 17b092ba39 | |||
| 4b09b0c47e | |||
| b64faed197 | |||
| 252d4ccfd8 | |||
| 721b12753c | |||
| 757e5e04d2 | |||
| 1d1e15446d | |||
| 69d5c7323f | |||
| 9a03814e14 | |||
| c52caa6b17 | |||
| 12c8519246 | |||
| 8bcd00e4a6 | |||
| 7649a607e6 | |||
| f89e4e6c56 | |||
| b9797cbe21 | |||
| cc38a0619c | |||
| aee0ba4cc5 | |||
| 9981ee4469 | |||
| 297d380f4c | |||
| 05062562f0 | |||
| 438f6e2237 | |||
| 458d4bb97a | |||
| 10949905f7 | |||
| 9ab5a4f2cc | |||
| f0a6a0a0af | |||
| cdda3ff277 | |||
| 08aa2ff02b | |||
| 37ace0a39a | |||
| 7f0eeba366 | |||
| f82bd5b871 | |||
| f117bccbbc | |||
| 8b59375404 | |||
| 91a1e3df6c | |||
| 7f12ef0355 | |||
| 30921550df | |||
| 2b96160ab0 | |||
| 7171589002 | |||
| 2a96f855ae | |||
| 3ac3f067cc | |||
| 535d58c80f | |||
| 2ef90b980f | |||
| e6654b96c5 | |||
| 5db28fd8e8 | |||
| c1207ef7fc | |||
| 70072e4281 | |||
| ba776a3fb8 | |||
| e2b24e2e25 | |||
| 7589046bbe | |||
| e248256649 | |||
| 76c1bd7578 | |||
| bcbced24e5 | |||
| a75c0a9965 | |||
| c505d6e32c | |||
| 7d053313b5 | |||
| e9535c8dc4 | |||
| 1642719445 | |||
| 3efab867c5 | |||
| 2ca89ce7c9 | |||
| 52e12d8d2a | |||
| c5b72451af | |||
| ffd8b153a5 | |||
| ee168d317f | |||
| a335925eae | |||
| cf1ba6b57b | |||
| ce7c95cae7 | |||
| 4b6a59826e | |||
| e6058168f0 | |||
| c7dde1f979 | |||
| 15a7585f6e | |||
| c486e5ba84 | |||
| d94833a678 | |||
| 376e950bd4 | |||
| 0959e790df | |||
| e134c4cba9 | |||
| 81e1536801 | |||
| 1a7cf409eb | |||
| 5f26d3964d | |||
| 4a4ad6b1df | |||
| 5702692940 | |||
| 6a4c1b452b | |||
| b75a09c88c | |||
| 0601520e9b | |||
| 412ed0be3c | |||
| 586aa9f8c3 | |||
| bc6470f5c3 | |||
| a107ed03a3 | |||
| 679b8cd948 | |||
| cece172df6 | |||
| 1a38a06e6e | |||
| fe691066dd | |||
| 0f47961a8c | |||
| 9299ee107a | |||
| 6680ed49f6 | |||
| a3eee9285e | |||
| 01a4eb6bbf | |||
| b8a419af5a | |||
| 2b61ebbfad | |||
| 4750869526 | |||
| c9745b6669 | |||
| 08b65f315a | |||
| e1aee6a44f | |||
| b3636a5bce | |||
| 908eebfb16 | |||
| 0e6b652dd7 | |||
| 0acdba6f01 | |||
| de2eccd209 | |||
| 873cafa46f | |||
| 948db57763 | |||
| f98a13eaa0 | |||
| 24ff491d3c | |||
| a8f2d0b110 | |||
| 46d2536600 | |||
| 71535581c2 | |||
| 26725286d5 | |||
| 569e921759 | |||
| 03ccce5cc1 | |||
| 64d5170c94 | |||
| 0710c13964 | |||
| 56cedec2ae | |||
| c0c8067bc5 | |||
| db3a034d75 | |||
| 059315d6ab | |||
| 8df7ba4f16 | |||
| 2bffea842b | |||
| 99cf21e61b | |||
| da5e26b923 | |||
| c957eaa3a1 | |||
| 73d01c0103 | |||
| 41a507fe8b | |||
| e02aa597f2 | |||
| 909305c82b | |||
| 121b2b294f | |||
| c2fe3322a7 | |||
| aac6b106aa | |||
| 7f6beba3ad | |||
| 4b354e7b9f | |||
| eed5297e9d | |||
| 9a5ca8c78f | |||
| 63a1d3c138 | |||
| 3caf9cf18e | |||
| 7d351e3422 | |||
| 9dea155505 | |||
| d3cdd3a1c7 | |||
| a9d1ddb84f | |||
| 6985934825 | |||
| 4f1766b501 | |||
| 7ff326d898 | |||
| 48f7c3cc61 | |||
| 892e01d608 | |||
| 54501cbd73 | |||
| f1d578fd18 | |||
| 2e3a5d9de5 | |||
| a6dad428e9 | |||
| 3d24f8f59e | |||
| 9106038a17 | |||
| 895ed9f605 | |||
| cbe14fda9b | |||
| 4c1dd89840 | |||
| d93eefe806 | |||
| e11f5fcd50 | |||
| 8e891dfcfe | |||
| c721dc23b6 | |||
| 1a2b7a1bbb | |||
| b194c0e11f | |||
| 6cff4420c9 | |||
| 12bf69def6 | |||
| 3818b4641f | |||
| 219b66dcc6 | |||
| 4e63e8559d |
@@ -0,0 +1,132 @@
|
||||
name: Build Native Module Prebuilds
|
||||
|
||||
on:
|
||||
# Build on releases
|
||||
release:
|
||||
types: [published]
|
||||
# Manual trigger for testing
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
electron_version:
|
||||
description: 'Electron version to build for'
|
||||
required: false
|
||||
default: '39.2.6'
|
||||
|
||||
env:
|
||||
# Default Electron version - update when upgrading Electron in package.json
|
||||
ELECTRON_VERSION: ${{ github.event.inputs.electron_version || '39.2.6' }}
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x64]
|
||||
# Add arm64 when GitHub Actions supports Windows ARM runners
|
||||
# arch: [x64, arm64]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install Visual Studio Build Tools
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Install node-pty and rebuild for Electron
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Install only node-pty
|
||||
pnpm add node-pty@1.1.0-beta42
|
||||
|
||||
# Get Electron ABI version
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
Write-Host "Building for Electron $env:ELECTRON_VERSION (ABI: $electronAbi)"
|
||||
|
||||
# Rebuild node-pty for Electron
|
||||
npx @electron/rebuild --version $env:ELECTRON_VERSION --module-dir node_modules/node-pty --arch ${{ matrix.arch }}
|
||||
|
||||
- name: Package prebuilt binaries
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
$prebuildDir = "prebuilds/win32-${{ matrix.arch }}-electron-$electronAbi"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $prebuildDir
|
||||
|
||||
# Copy all built native files
|
||||
$buildDir = "node_modules/node-pty/build/Release"
|
||||
if (Test-Path $buildDir) {
|
||||
Copy-Item "$buildDir/*.node" $prebuildDir/ -Force
|
||||
Copy-Item "$buildDir/*.dll" $prebuildDir/ -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item "$buildDir/*.exe" $prebuildDir/ -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Also copy conpty files if they exist in subdirectory
|
||||
if (Test-Path "$buildDir/conpty") {
|
||||
Copy-Item "$buildDir/conpty/*" $prebuildDir/ -Force
|
||||
}
|
||||
}
|
||||
|
||||
# List what we packaged
|
||||
Write-Host "Packaged prebuilds:"
|
||||
Get-ChildItem $prebuildDir
|
||||
|
||||
- name: Create archive
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
$archiveName = "node-pty-win32-${{ matrix.arch }}-electron-$electronAbi.zip"
|
||||
|
||||
Compress-Archive -Path "prebuilds/*" -DestinationPath $archiveName
|
||||
|
||||
Write-Host "Created archive: $archiveName"
|
||||
Get-ChildItem $archiveName
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-pty-win32-${{ matrix.arch }}
|
||||
path: auto-claude-ui/node-pty-*.zip
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload to release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: auto-claude-ui/node-pty-*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Create a combined prebuilds package
|
||||
package-prebuilds:
|
||||
needs: build-windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: List artifacts
|
||||
run: |
|
||||
echo "Downloaded artifacts:"
|
||||
find artifacts -type f -name "*.zip"
|
||||
|
||||
- name: Upload combined artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-pty-prebuilds-all
|
||||
path: artifacts/**/*.zip
|
||||
retention-days: 90
|
||||
@@ -0,0 +1,71 @@
|
||||
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('./auto-claude-ui/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 }}"
|
||||
@@ -74,6 +74,7 @@ dmypy.json
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.claude_settings.json
|
||||
.update-metadata.json
|
||||
|
||||
# Development of Auto Build with Auto Build
|
||||
dev/
|
||||
|
||||
+403
@@ -1,3 +1,406 @@
|
||||
## 2.6.0 - Improved User Experience and Agent Configuration
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Add customizable phase configuration in app settings, allowing users to tailor the AI build pipeline to their workflow
|
||||
|
||||
- Implement parallel AI merge functionality for faster integration of completed builds
|
||||
|
||||
- Add Google AI as LLM and embedding provider for Graphiti memory system
|
||||
|
||||
- Implement device code authentication flow with timeout handling, browser launch fallback, and comprehensive testing
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Move Agent Profiles from dashboard to Settings for better organization and discoverability
|
||||
|
||||
- Default agent profile to 'Auto (Optimized)' for streamlined out-of-the-box experience
|
||||
|
||||
- Enhance WorkspaceStatus component UI with improved visual design
|
||||
|
||||
- Refactor task management from sidebar to modal interface for cleaner navigation
|
||||
|
||||
- Add comprehensive theme system with multiple color schemes (Forest, Neo, Retro, Dusk, Ocean, Lime) and light/dark mode support
|
||||
|
||||
- Extract human-readable feature titles from spec.md for better task identification
|
||||
|
||||
- Improve task description display for specs with compact markdown formatting
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Fix asyncio coroutine creation in worker threads to properly support async operations
|
||||
|
||||
- Improve UX for phase configuration in task creation workflow
|
||||
|
||||
- Address CodeRabbit PR #69 feedback and additional review comments
|
||||
|
||||
- Fix auto-close behavior for task modal when marking tasks as done
|
||||
|
||||
- Resolve Python lint errors and import sorting issues (ruff I001 compliance)
|
||||
|
||||
- Ensure planner agent properly writes implementation_plan.json
|
||||
|
||||
- Add platform detection for terminal profile commands on Windows
|
||||
|
||||
- Set default selected agent profile to 'auto' across all users
|
||||
|
||||
- Fix display of correct merge target branch in worktree UI
|
||||
|
||||
- Add validation for invalid colorTheme fallback to prevent UI errors
|
||||
|
||||
- Remove outdated Sun/Moon toggle button from sidebar
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat: add customizable phase configuration in app settings by @AndyMik90 in aee0ba4
|
||||
|
||||
- feat: implement parallel AI merge functionality by @AndyMik90 in 458d4bb
|
||||
|
||||
- feat(graphiti): add Google AI as LLM and embedding provider by @adryserage in fe69106
|
||||
|
||||
- fix: create coroutine inside worker thread for asyncio.run by @AndyMik90 in f89e4e6
|
||||
|
||||
- fix: improve UX for phase configuration in task creation by @AndyMik90 in b9797cb
|
||||
|
||||
- fix: address CodeRabbit PR #69 feedback by @AndyMik90 in cc38a06
|
||||
|
||||
- fix: sort imports in workspace.py to pass ruff I001 check by @AndyMik90 in 9981ee4
|
||||
|
||||
- fix(ui): auto-close task modal when marking task as done by @AndyMik90 in 297d380
|
||||
|
||||
- fix: resolve Python lint errors in workspace.py by @AndyMik90 in 0506256
|
||||
|
||||
- refactor: move Agent Profiles from dashboard to Settings by @AndyMik90 in 1094990
|
||||
|
||||
- fix(planning): ensure planner agent writes implementation_plan.json by @AndyMik90 in 9ab5a4f
|
||||
|
||||
- fix(windows): add platform detection for terminal profile commands by @AndyMik90 in f0a6a0a
|
||||
|
||||
- fix: default agent profile to 'Auto (Optimized)' for all users by @AndyMik90 in 08aa2ff
|
||||
|
||||
- fix: update default selected agent profile to 'auto' by @AndyMik90 in 37ace0a
|
||||
|
||||
- style: enhance WorkspaceStatus component UI by @AndyMik90 in 3092155
|
||||
|
||||
- fix: display correct merge target branch in worktree UI by @AndyMik90 in 2b96160
|
||||
|
||||
- Improvement/refactor task sidebar to task modal by @AndyMik90 in 2a96f85
|
||||
|
||||
- fix: extract human-readable title from spec.md when feature field is spec ID by @AndyMik90 in 8b59375
|
||||
|
||||
- fix: task descriptions not showing for specs with compact markdown by @AndyMik90 in 7f12ef0
|
||||
|
||||
- Add comprehensive theme system with Forest, Neo, Retro, Dusk, Ocean, and Lime color schemes by @AndyMik90 in ba776a3, e2b24e2, 7589046, e248256, 76c1bd7, bcbced2
|
||||
|
||||
- Add ColorTheme type and configuration to app settings by @AndyMik90 in 2ca89ce, c505d6e, a75c0a9
|
||||
|
||||
- Implement device code authentication flow with timeout handling and fallback URL display by @AndyMik90 in 5f26d39, 81e1536, 1a7cf40, 4a4ad6b, 6a4c1b4, b75a09c, e134c4c
|
||||
|
||||
- fix(graphiti): address CodeRabbit review comments by @adryserage in 679b8cd
|
||||
|
||||
- fix(lint): sort imports in Google provider files by @adryserage in 1a38a06
|
||||
|
||||
## 2.6.0 - Multi-Provider Graphiti Support & Platform Fixes
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Google AI Provider for Graphiti**: Full Google AI (Gemini) support for both LLM and embeddings in the Memory Layer
|
||||
- Add GoogleLLMClient with gemini-2.0-flash default model
|
||||
- Add GoogleEmbedder with text-embedding-004 default model
|
||||
- UI integration for Google API key configuration with link to Google AI Studio
|
||||
- **Ollama LLM Provider in UI**: Add Ollama as an LLM provider option in Graphiti onboarding wizard
|
||||
- Ollama runs locally and doesn't require an API key
|
||||
- Configure Base URL instead of API key for local inference
|
||||
- **LLM Provider Selection UI**: Add provider selection dropdown to Graphiti setup wizard for flexible backend configuration
|
||||
- **Per-Project GitHub Configuration**: UI clarity improvements for per-project GitHub org/repo settings
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Enhanced Graphiti provider factory to support Google AI alongside existing providers
|
||||
- Updated env-handlers to properly populate graphitiProviderConfig from .env files
|
||||
- Improved type definitions with proper Graphiti provider config properties in AppSettings
|
||||
- Better API key loading when switching between providers in settings
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **node-pty Migration**: Replaced node-pty with @lydell/node-pty for prebuilt Windows binaries
|
||||
- Updated all imports to use @lydell/node-pty directly
|
||||
- Fixed "Cannot find module 'node-pty'" startup error
|
||||
- **GitHub Organization Support**: Fixed repository support for GitHub organization accounts
|
||||
- Add defensive array validation for GitHub issues API response
|
||||
- **Asyncio Deprecation**: Fixed asyncio deprecation warning by using get_running_loop() instead of get_event_loop()
|
||||
- Applied ruff formatting and fixed import sorting (I001) in Google provider files
|
||||
|
||||
### 🔧 Other Changes
|
||||
|
||||
- Added google-generativeai dependency to requirements.txt
|
||||
- Updated provider validation to include Google/Groq/HuggingFace type assertions
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(graphiti): address CodeRabbit review comments by @adryserage in 679b8cd
|
||||
- fix(lint): sort imports in Google provider files by @adryserage in 1a38a06
|
||||
- feat(graphiti): add Google AI as LLM and embedding provider by @adryserage in fe69106
|
||||
- fix: GitHub organization repository support by @mojaray2k in 873cafa
|
||||
- feat(ui): add LLM provider selection to Graphiti onboarding by @adryserage in 4750869
|
||||
- fix(types): add missing AppSettings properties for Graphiti providers by @adryserage in 6680ed4
|
||||
- feat(ui): add Ollama as LLM provider option for Graphiti by @adryserage in a3eee92
|
||||
- fix(ui): address PR review feedback for Graphiti provider selection by @adryserage in b8a419a
|
||||
- fix(deps): update imports to use @lydell/node-pty directly by @adryserage in 2b61ebb
|
||||
- fix(deps): replace node-pty with @lydell/node-pty for prebuilt binaries by @adryserage in e1aee6a
|
||||
- fix: add UI clarity for per-project GitHub configuration by @mojaray2k in c9745b6
|
||||
- fix: add defensive array validation for GitHub issues API response by @mojaray2k in b3636a5
|
||||
|
||||
---
|
||||
|
||||
## 2.5.5 - Enhanced Agent Reliability & Build Workflow
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Required GitHub setup flow after Auto Claude initialization to ensure proper configuration
|
||||
- Atomic log saving mechanism to prevent log file corruption during concurrent operations
|
||||
- Per-session model and thinking level selection in insights management
|
||||
- Multi-auth token support and ANTHROPIC_BASE_URL passthrough for flexible authentication
|
||||
- Comprehensive DEBUG logging at Claude SDK invocation points for improved troubleshooting
|
||||
- Auto-download of prebuilt node-pty binaries for Windows environments
|
||||
- Enhanced merge workflow with current branch detection for accurate change previews
|
||||
- Phase configuration module and enhanced agent profiles for improved flexibility
|
||||
- Stage-only merge handling with comprehensive verification checks
|
||||
- Authentication failure detection system with patterns and validation checks across agent pipeline
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Changed default agent profile from 'balanced' to 'auto' for more adaptive behavior
|
||||
- Better GitHub issue tracking and improved user experience in issue management
|
||||
- Improved merge preview accuracy using git diff counts for file statistics
|
||||
- Preserved roadmap generation state when switching between projects
|
||||
- Enhanced agent profiles with phase configuration support
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Resolved CI test failures and improved merge preview reliability
|
||||
- Fixed CI failures related to linting, formatting, and tests
|
||||
- Prevented dialog skip during project initialization flow
|
||||
- Updated model IDs for Sonnet and Haiku to match current Claude versions
|
||||
- Fixed branch namespace conflict detection to prevent worktree creation failures
|
||||
- Removed duplicate LINEAR_API_KEY checks and consolidated imports
|
||||
- Python 3.10+ version requirement enforced with proper version checking
|
||||
- Prevented command injection vulnerabilities in GitHub API calls
|
||||
|
||||
### 🔧 Other Changes
|
||||
|
||||
- Code cleanup and test fixture updates
|
||||
- Removed redundant auto-claude/specs directory structure
|
||||
- Untracked .auto-claude directory to respect gitignore rules
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix: resolve CI test failures and improve merge preview by @AndyMik90 in de2eccd
|
||||
- chore: code cleanup and test fixture updates by @AndyMik90 in 948db57
|
||||
- refactor: change default agent profile from 'balanced' to 'auto' by @AndyMik90 in f98a13e
|
||||
- security: prevent command injection in GitHub API calls by @AndyMik90 in 24ff491
|
||||
- fix: resolve CI failures (lint, format, test) by @AndyMik90 in a8f2d0b
|
||||
- fix: use git diff count for totalFiles in merge preview by @AndyMik90 in 46d2536
|
||||
- feat: enhance stage-only merge handling with verification checks by @AndyMik90 in 7153558
|
||||
- feat: introduce phase configuration module and enhance agent profiles by @AndyMik90 in 2672528
|
||||
- fix: preserve roadmap generation state when switching projects by @AndyMik90 in 569e921
|
||||
- feat: add required GitHub setup flow after Auto Claude initialization by @AndyMik90 in 03ccce5
|
||||
- chore: remove redundant auto-claude/specs directory by @AndyMik90 in 64d5170
|
||||
- chore: untrack .auto-claude directory (should be gitignored) by @AndyMik90 in 0710c13
|
||||
- fix: prevent dialog skip during project initialization by @AndyMik90 in 56cedec
|
||||
- feat: enhance merge workflow by detecting current branch by @AndyMik90 in c0c8067
|
||||
- fix: update model IDs for Sonnet and Haiku by @AndyMik90 in 059315d
|
||||
- feat: add comprehensive DEBUG logging and fix lint errors by @AndyMik90 in 99cf21e
|
||||
- feat: implement atomic log saving to prevent corruption by @AndyMik90 in da5e26b
|
||||
- feat: add better github issue tracking and UX by @AndyMik90 in c957eaa
|
||||
- feat: add comprehensive DEBUG logging to Claude SDK invocation points by @AndyMik90 in 73d01c0
|
||||
- feat: auto-download prebuilt node-pty binaries for Windows by @AndyMik90 in 41a507f
|
||||
- feat(insights): add per-session model and thinking level selection by @AndyMik90 in e02aa59
|
||||
- fix: require Python 3.10+ and add version check by @AndyMik90 in 9a5ca8c
|
||||
- fix: detect branch namespace conflict blocking worktree creation by @AndyMik90 in 63a1d3c
|
||||
- fix: remove duplicate LINEAR_API_KEY check and consolidate imports by @Jacob in 7d351e3
|
||||
- feat: add multi-auth token support and ANTHROPIC_BASE_URL passthrough by @Jacob in 9dea155
|
||||
|
||||
## 2.5.0 - Roadmap Intelligence & Workflow Refinements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Interactive competitor analysis viewer for roadmap planning with real-time data visualization
|
||||
|
||||
- GitHub issue label mapping to task categories for improved organization and tracking
|
||||
|
||||
- GitHub issue comment selection in task creation workflow for better context integration
|
||||
|
||||
- TaskCreationWizard enhanced with drag-and-drop support for file references and inline @mentions
|
||||
|
||||
- Roadmap generation now includes stop functionality and comprehensive debug logging
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Refined visual drop zone feedback in file reference system for more subtle user guidance
|
||||
|
||||
- Remove auto-expand behavior for referenced files on draft restore to improve UX
|
||||
|
||||
- Always-visible referenced files section in TaskCreationWizard for better discoverability
|
||||
|
||||
- Drop zone wrapper added around main modal content area for improved drag-and-drop ergonomics
|
||||
|
||||
- Stuck task detection now enabled for ai_review status to better track blocked work
|
||||
|
||||
- Enhanced React component stability with proper key usage in RoadmapHeader and PhaseProgressIndicator
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Corrected CompetitorAnalysisViewer type definitions for proper TypeScript compliance
|
||||
|
||||
- Fixed multiple CodeRabbit review feedback items for improved code quality
|
||||
|
||||
- Resolved React key warnings in PhaseProgressIndicator component
|
||||
|
||||
- Fixed git status parsing in merge preview for accurate worktree state detection
|
||||
|
||||
- Corrected path resolution in runners for proper module imports and .env loading
|
||||
|
||||
- Resolved CI lint and TypeScript errors across codebase
|
||||
|
||||
- Fixed HTTP error handling and path resolution issues in core modules
|
||||
|
||||
- Corrected worktree test to match intended branch detection behavior
|
||||
|
||||
- Refined TaskReview component conditional rendering for proper staged task display
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat: add interactive competitor analysis viewer for roadmap by @AndyMik90 in 7ff326d
|
||||
|
||||
- fix: correct CompetitorAnalysisViewer to match type definitions by @AndyMik90 in 4f1766b
|
||||
|
||||
- fix: address multiple CodeRabbit review feedback items by @AndyMik90 in 48f7c3c
|
||||
|
||||
- fix: use stable React keys instead of array indices in RoadmapHeader by @AndyMik90 in 892e01d
|
||||
|
||||
- fix: additional fixes for http error handling and path resolution by @AndyMik90 in 54501cb
|
||||
|
||||
- fix: update worktree test to match intended branch detection behavior by @AndyMik90 in f1d578f
|
||||
|
||||
- fix: resolve CI lint and TypeScript errors by @AndyMik90 in 2e3a5d9
|
||||
|
||||
- feat: enhance roadmap generation with stop functionality and debug logging by @AndyMik90 in a6dad42
|
||||
|
||||
- fix: correct path resolution in runners for module imports and .env loading by @AndyMik90 in 3d24f8f
|
||||
|
||||
- fix: resolve React key warning in PhaseProgressIndicator by @AndyMik90 in 9106038
|
||||
|
||||
- fix: enable stuck task detection for ai_review status by @AndyMik90 in 895ed9f
|
||||
|
||||
- feat: map GitHub issue labels to task categories by @AndyMik90 in cbe14fd
|
||||
|
||||
- feat: add GitHub issue comment selection and fix auto-start bug by @AndyMik90 in 4c1dd89
|
||||
|
||||
- feat: enhance TaskCreationWizard with drag-and-drop support for file references and inline @mentions by @AndyMik90 in d93eefe
|
||||
|
||||
- cleanup docs by @AndyMik90 in 8e891df
|
||||
|
||||
- fix: correct git status parsing in merge preview by @AndyMik90 in c721dc2
|
||||
|
||||
- Update TaskReview component to refine conditional rendering for staged tasks, ensuring proper display when staging is unsuccessful by @AndyMik90 in 1a2b7a1
|
||||
|
||||
- auto-claude: subtask-2-3 - Refine visual drop zone feedback to be more subtle by @AndyMik90 in 6cff442
|
||||
|
||||
- auto-claude: subtask-2-1 - Remove showFiles auto-expand on draft restore by @AndyMik90 in 12bf69d
|
||||
|
||||
- auto-claude: subtask-1-3 - Create an always-visible referenced files section by @AndyMik90 in 3818b46
|
||||
|
||||
- auto-claude: subtask-1-2 - Add drop zone wrapper around main modal content area by @AndyMik90 in 219b66d
|
||||
|
||||
- auto-claude: subtask-1-1 - Remove Reference Files toggle button by @AndyMik90 in 4e63e85
|
||||
|
||||
## 2.4.0 - Enhanced Cross-Platform Experience with OAuth & Auto-Updates
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Claude account OAuth implementation on onboarding for seamless token setup
|
||||
|
||||
- Integrated release workflow with AI-powered version suggestion capabilities
|
||||
|
||||
- Auto-upgrading functionality supporting Windows, Linux, and macOS with automatic app updates
|
||||
|
||||
- Git repository initialization on app startup with project addition checks
|
||||
|
||||
- Debug logging for app updater to track update processes
|
||||
|
||||
- Auto-open settings to updates section when app update is ready
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Major Windows and Linux compatibility enhancements for cross-platform reliability
|
||||
|
||||
- Enhanced task status handling to support 'done' status in limbo state with worktree existence checks
|
||||
|
||||
- Better handling of lock files from worktrees upon merging
|
||||
|
||||
- Improved README documentation and build process
|
||||
|
||||
- Refined visual drop zone feedback for more subtle user experience
|
||||
|
||||
- Removed showFiles auto-expand on draft restore for better UX consistency
|
||||
|
||||
- Created always-visible referenced files section in task creation wizard
|
||||
|
||||
- Removed Reference Files toggle button for streamlined interface
|
||||
|
||||
- Worktree manual deletion enforcement for early access safety (prevents accidental work loss)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Corrected git status parsing in merge preview functionality
|
||||
|
||||
- Fixed ESLint warnings and failing tests
|
||||
|
||||
- Fixed Windows/Linux Python handling for cross-platform compatibility
|
||||
|
||||
- Fixed Windows/Linux source path detection
|
||||
|
||||
- Refined TaskReview component conditional rendering for proper staged task display
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- docs: cleanup docs by @AndyMik90 in 8e891df
|
||||
- fix: correct git status parsing in merge preview by @AndyMik90 in c721dc2
|
||||
- refactor: Update TaskReview component to refine conditional rendering for staged tasks by @AndyMik90 in 1a2b7a1
|
||||
- feat: Enhance task status handling to allow 'done' status in limbo state by @AndyMik90 in a20b8cf
|
||||
- improvement: Worktree needs to be manually deleted for early access safety by @AndyMik90 in 0ed6afb
|
||||
- feat: Claude account OAuth implementation on onboarding by @AndyMik90 in 914a09d
|
||||
- fix: Better handling of lock files from worktrees upon merging by @AndyMik90 in e44202a
|
||||
- feat: GitHub OAuth integration upon onboarding by @AndyMik90 in 4249644
|
||||
- chore: lock update by @AndyMik90 in b0fc497
|
||||
- improvement: Improved README and build process by @AndyMik90 in 462edcd
|
||||
- fix: ESLint warnings and failing tests by @AndyMik90 in affbc48
|
||||
- feat: Major Windows and Linux compatibility enhancements with auto-upgrade by @AndyMik90 in d7fd1a2
|
||||
- feat: Add debug logging to app updater by @AndyMik90 in 96dd04d
|
||||
- feat: Auto-open settings to updates section when app update is ready by @AndyMik90 in 1d0566f
|
||||
- feat: Add integrated release workflow with AI version suggestion by @AndyMik90 in 7f3cd59
|
||||
- fix: Windows/Linux Python handling by @AndyMik90 in 0ef0e15
|
||||
- feat: Implement Electron app auto-updater by @AndyMik90 in efc112a
|
||||
- fix: Windows/Linux source path detection by @AndyMik90 in d33a0aa
|
||||
- refactor: Refine visual drop zone feedback to be more subtle by @AndyMik90 in 6cff442
|
||||
- refactor: Remove showFiles auto-expand on draft restore by @AndyMik90 in 12bf69d
|
||||
- feat: Create always-visible referenced files section by @AndyMik90 in 3818b46
|
||||
- feat: Add drop zone wrapper around main modal content by @AndyMik90 in 219b66d
|
||||
- feat: Remove Reference Files toggle button by @AndyMik90 in 4e63e85
|
||||
- docs: Update README with git initialization and folder structure by @AndyMik90 in 2fa3c51
|
||||
- chore: Version bump to 2.3.2 by @AndyMik90 in 59b091a
|
||||
|
||||
## 2.3.2 - UI Polish & Build Improvements
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
@@ -81,6 +81,21 @@ auto-claude/.venv/bin/pytest tests/ -m "not slow"
|
||||
python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature --checkpoint all
|
||||
```
|
||||
|
||||
### Releases
|
||||
```bash
|
||||
# Automated version bump and release (recommended)
|
||||
node scripts/bump-version.js patch # 2.5.5 -> 2.5.6
|
||||
node scripts/bump-version.js minor # 2.5.5 -> 2.6.0
|
||||
node scripts/bump-version.js major # 2.5.5 -> 3.0.0
|
||||
node scripts/bump-version.js 2.6.0 # Set specific version
|
||||
|
||||
# Then push to trigger GitHub release workflows
|
||||
git push origin main
|
||||
git push origin v2.6.0
|
||||
```
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for detailed release process documentation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Pipeline
|
||||
@@ -103,7 +118,7 @@ python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature --c
|
||||
- **worktree.py** - Git worktree isolation for safe feature development
|
||||
- **memory.py** - File-based session memory (primary, always-available storage)
|
||||
- **graphiti_memory.py** - Optional graph-based cross-session memory with semantic search
|
||||
- **graphiti_providers.py** - Multi-provider factory for Graphiti (OpenAI, Anthropic, Azure, Ollama)
|
||||
- **graphiti_providers.py** - Multi-provider factory for Graphiti (OpenAI, Anthropic, Azure, Ollama, Google AI)
|
||||
- **graphiti_config.py** - Configuration and validation for Graphiti integration
|
||||
- **linear_updater.py** - Optional Linear integration for progress tracking
|
||||
|
||||
@@ -177,8 +192,8 @@ Dual-layer memory architecture:
|
||||
- Graph database with semantic search (FalkorDB)
|
||||
- Cross-session context retrieval
|
||||
- Multi-provider support (V2):
|
||||
- LLM: OpenAI, Anthropic, Azure OpenAI, Ollama
|
||||
- Embedders: OpenAI, Voyage AI, Azure OpenAI, Ollama
|
||||
- LLM: OpenAI, Anthropic, Azure OpenAI, Ollama, Google AI (Gemini)
|
||||
- Embedders: OpenAI, Voyage AI, Azure OpenAI, Ollama, Google AI
|
||||
|
||||
Enable with: `GRAPHITI_ENABLED=true` + provider credentials. See `.env.example`.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Your AI coding companion. Build features, fix bugs, and ship faster — with aut
|
||||
|
||||

|
||||
|
||||
[](https://discord.gg/maj9EWmY)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
|
||||
## What It Does ✨
|
||||
|
||||
@@ -35,7 +35,7 @@ The Desktop UI is the recommended way to use Auto Claude. It provides visual tas
|
||||
### Prerequisites
|
||||
|
||||
1. **Node.js 18+** - [Download Node.js](https://nodejs.org/)
|
||||
2. **Python 3.9+** - [Download Python](https://www.python.org/downloads/)
|
||||
2. **Python 3.10+** - [Download Python](https://www.python.org/downloads/)
|
||||
3. **Docker Desktop** - Required for the Memory Layer
|
||||
4. **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
|
||||
5. **Claude Subscription** - Requires [Claude Pro or Max](https://claude.ai/upgrade) for Claude Code access
|
||||
@@ -114,6 +114,18 @@ pnpm run build && pnpm run start
|
||||
# or: npm run build && npm run start
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Windows users:</b> If installation fails with node-gyp errors, click here</summary>
|
||||
|
||||
Auto Claude automatically downloads prebuilt binaries for Windows. If prebuilts aren't available for your Electron version yet, you'll need Visual Studio Build Tools:
|
||||
|
||||
1. Download [Visual Studio Build Tools 2022](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||
2. Select "Desktop development with C++" workload
|
||||
3. In "Individual Components", add "MSVC v143 - VS 2022 C++ x64/x86 Spectre-mitigated libs"
|
||||
4. Restart terminal and run `npm install` again
|
||||
|
||||
</details>
|
||||
|
||||
### Step 4: Start Building
|
||||
|
||||
1. Add your project in the UI
|
||||
@@ -236,12 +248,13 @@ The Memory Layer is a **hybrid RAG system** combining graph nodes with semantic
|
||||
**Architecture:**
|
||||
- **Backend**: FalkorDB (graph database) via Docker
|
||||
- **Library**: Graphiti for knowledge graph operations
|
||||
- **Providers**: OpenAI, Anthropic, Azure OpenAI, or Ollama (local/offline)
|
||||
- **Providers**: OpenAI, Anthropic, Azure OpenAI, Google AI, or Ollama (local/offline)
|
||||
|
||||
| Setup | LLM | Embeddings | Notes |
|
||||
|-------|-----|------------|-------|
|
||||
| **OpenAI** | OpenAI | OpenAI | Simplest - single API key |
|
||||
| **Anthropic + Voyage** | Anthropic | Voyage AI | High quality |
|
||||
| **Google AI** | Gemini | Google | Single API key, fast inference |
|
||||
| **Ollama** | Ollama | Ollama | Fully offline |
|
||||
| **Azure** | Azure OpenAI | Azure OpenAI | Enterprise |
|
||||
|
||||
@@ -297,11 +310,12 @@ The `.auto-claude/` directory is gitignored and project-specific - you'll have o
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
|
||||
| `AUTO_BUILD_MODEL` | No | Model override (default: claude-opus-4-5-20251101) |
|
||||
| `GRAPHITI_ENABLED` | Recommended | Set to `true` to enable Memory Layer |
|
||||
| `GRAPHITI_LLM_PROVIDER` | For Memory | LLM provider: openai, anthropic, azure_openai, ollama |
|
||||
| `GRAPHITI_EMBEDDER_PROVIDER` | For Memory | Embedder: openai, voyage, azure_openai, ollama |
|
||||
| `GRAPHITI_LLM_PROVIDER` | For Memory | LLM provider: openai, anthropic, azure_openai, ollama, google |
|
||||
| `GRAPHITI_EMBEDDER_PROVIDER` | For Memory | Embedder: openai, voyage, azure_openai, ollama, google |
|
||||
| `OPENAI_API_KEY` | For OpenAI | Required for OpenAI provider |
|
||||
| `ANTHROPIC_API_KEY` | For Anthropic | Required for Anthropic LLM |
|
||||
| `VOYAGE_API_KEY` | For Voyage | Required for Voyage embeddings |
|
||||
| `GOOGLE_API_KEY` | For Google | Required for Google AI (Gemini) provider |
|
||||
|
||||
See `auto-claude/.env.example` for complete configuration options.
|
||||
|
||||
@@ -309,7 +323,7 @@ See `auto-claude/.env.example` for complete configuration options.
|
||||
|
||||
Join our Discord to get help, share what you're building, and connect with other Auto Claude users:
|
||||
|
||||
[](https://discord.gg/maj9EWmY)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# Release Process
|
||||
|
||||
This document describes how to create a new release of Auto Claude.
|
||||
|
||||
## Automated Release Process (Recommended)
|
||||
|
||||
We provide an automated script that handles version bumping, git commits, and tagging to ensure version consistency.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Clean git working directory (no uncommitted changes)
|
||||
- You're on the branch you want to release from (usually `main`)
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Run the version bump script:**
|
||||
|
||||
```bash
|
||||
# Bump patch version (2.5.5 -> 2.5.6)
|
||||
node scripts/bump-version.js patch
|
||||
|
||||
# Bump minor version (2.5.5 -> 2.6.0)
|
||||
node scripts/bump-version.js minor
|
||||
|
||||
# Bump major version (2.5.5 -> 3.0.0)
|
||||
node scripts/bump-version.js major
|
||||
|
||||
# Set specific version
|
||||
node scripts/bump-version.js 2.6.0
|
||||
```
|
||||
|
||||
This script will:
|
||||
- ✅ Update `auto-claude-ui/package.json` with the new version
|
||||
- ✅ Create a git commit with the version change
|
||||
- ✅ Create a git tag (e.g., `v2.5.6`)
|
||||
- ⚠️ **NOT** push to remote (you control when to push)
|
||||
|
||||
2. **Review the changes:**
|
||||
|
||||
```bash
|
||||
git log -1 # View the commit
|
||||
git show v2.5.6 # View the tag
|
||||
```
|
||||
|
||||
3. **Push to GitHub:**
|
||||
|
||||
```bash
|
||||
# Push the commit
|
||||
git push origin main
|
||||
|
||||
# Push the tag
|
||||
git push origin v2.5.6
|
||||
```
|
||||
|
||||
4. **Create GitHub Release:**
|
||||
|
||||
- Go to [GitHub Releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
- Click "Draft a new release"
|
||||
- Select the tag you just pushed (e.g., `v2.5.6`)
|
||||
- Add release notes (describe what changed)
|
||||
- Click "Publish release"
|
||||
|
||||
5. **Automated builds will trigger:**
|
||||
|
||||
- ✅ Version validation workflow will verify version consistency
|
||||
- ✅ Tests will run (`test-on-tag.yml`)
|
||||
- ✅ Native module prebuilds will be created (`build-prebuilds.yml`)
|
||||
- ✅ Discord notification will be sent (`discord-release.yml`)
|
||||
|
||||
## Manual Release Process (Not Recommended)
|
||||
|
||||
If you need to create a release manually, follow these steps **carefully** to avoid version mismatches:
|
||||
|
||||
1. **Update `auto-claude-ui/package.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.5.6"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Commit the change:**
|
||||
|
||||
```bash
|
||||
git add auto-claude-ui/package.json
|
||||
git commit -m "chore: bump version to 2.5.6"
|
||||
```
|
||||
|
||||
3. **Create and push tag:**
|
||||
|
||||
```bash
|
||||
git tag -a v2.5.6 -m "Release v2.5.6"
|
||||
git push origin main
|
||||
git push origin v2.5.6
|
||||
```
|
||||
|
||||
4. **Create GitHub Release** (same as step 4 above)
|
||||
|
||||
## Version Validation
|
||||
|
||||
A GitHub Action automatically validates that the version in `package.json` matches the git tag.
|
||||
|
||||
If there's a mismatch, the workflow will **fail** with a clear error message:
|
||||
|
||||
```
|
||||
❌ ERROR: Version mismatch detected!
|
||||
|
||||
The version in package.json (2.5.0) does not match
|
||||
the git tag version (2.5.5).
|
||||
|
||||
To fix this:
|
||||
1. Delete this tag: git tag -d v2.5.5
|
||||
2. Update package.json version to 2.5.5
|
||||
3. Commit the change
|
||||
4. Recreate the tag: git tag -a v2.5.5 -m 'Release v2.5.5'
|
||||
```
|
||||
|
||||
This validation ensures we never ship a release where the updater shows the wrong version.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Version Mismatch Error
|
||||
|
||||
If you see a version mismatch error in GitHub Actions:
|
||||
|
||||
1. **Delete the incorrect tag:**
|
||||
```bash
|
||||
git tag -d v2.5.6 # Delete locally
|
||||
git push origin :refs/tags/v2.5.6 # Delete remotely
|
||||
```
|
||||
|
||||
2. **Use the automated script:**
|
||||
```bash
|
||||
node scripts/bump-version.js 2.5.6
|
||||
git push origin main
|
||||
git push origin v2.5.6
|
||||
```
|
||||
|
||||
### Git Working Directory Not Clean
|
||||
|
||||
If the version bump script fails with "Git working directory is not clean":
|
||||
|
||||
```bash
|
||||
# Commit or stash your changes first
|
||||
git status
|
||||
git add .
|
||||
git commit -m "your changes"
|
||||
|
||||
# Then run the version bump script
|
||||
node scripts/bump-version.js patch
|
||||
```
|
||||
|
||||
## Release Checklist
|
||||
|
||||
Use this checklist when creating a new release:
|
||||
|
||||
- [ ] All tests passing on main branch
|
||||
- [ ] CHANGELOG updated (if applicable)
|
||||
- [ ] Run `node scripts/bump-version.js <type>`
|
||||
- [ ] Review commit and tag
|
||||
- [ ] Push commit and tag to GitHub
|
||||
- [ ] Create GitHub Release with release notes
|
||||
- [ ] Verify version validation passed
|
||||
- [ ] Verify builds completed successfully
|
||||
- [ ] Test the updater shows correct version
|
||||
|
||||
## What Gets Released
|
||||
|
||||
When you create a release, the following are built and published:
|
||||
|
||||
1. **Native module prebuilds** - Windows node-pty binaries
|
||||
2. **Electron app packages** - Desktop installers (triggered manually or via electron-builder)
|
||||
3. **Discord notification** - Sent to the Auto Claude community
|
||||
|
||||
## Version Numbering
|
||||
|
||||
We follow [Semantic Versioning (SemVer)](https://semver.org/):
|
||||
|
||||
- **MAJOR** version (X.0.0) - Breaking changes
|
||||
- **MINOR** version (0.X.0) - New features (backward compatible)
|
||||
- **PATCH** version (0.0.X) - Bug fixes (backward compatible)
|
||||
|
||||
Examples:
|
||||
- `2.5.5 -> 2.5.6` - Bug fix
|
||||
- `2.5.6 -> 2.6.0` - New feature
|
||||
- `2.6.0 -> 3.0.0` - Breaking change
|
||||
@@ -1,312 +0,0 @@
|
||||
# Auto Claude Update System Analysis
|
||||
|
||||
## Current State
|
||||
|
||||
The app has **TWO separate update systems** for different components:
|
||||
|
||||
### 1. ✅ Auto Claude Framework Updates (WORKING)
|
||||
|
||||
**What it updates:** The Python framework source code (`auto-claude/` directory)
|
||||
|
||||
**How it works:**
|
||||
- Checks GitHub Releases API for new versions
|
||||
- Downloads release tarball
|
||||
- Extracts and applies update to the bundled source
|
||||
- Preserves user configuration files (.env, etc.)
|
||||
|
||||
**User Experience:**
|
||||
- **Settings > Advanced > Updates** section
|
||||
- Visual update checker with version display
|
||||
- Release notes rendered in UI
|
||||
- Progress bar during download
|
||||
- One-click update button
|
||||
- Works across all platforms (macOS, Windows, Linux)
|
||||
|
||||
**Files:**
|
||||
- `auto-claude-ui/src/main/auto-claude-updater.ts` - Main updater module
|
||||
- `auto-claude-ui/src/main/updater/update-checker.ts` - Update checking
|
||||
- `auto-claude-ui/src/main/updater/update-installer.ts` - Download & install
|
||||
- `auto-claude-ui/src/main/ipc-handlers/autobuild-source-handlers.ts` - IPC handlers
|
||||
- `auto-claude-ui/src/renderer/components/settings/AdvancedSettings.tsx` - UI
|
||||
|
||||
**Status:** ✅ **FULLY FUNCTIONAL** - Non-technical users can update the framework with one click!
|
||||
|
||||
---
|
||||
|
||||
### 2. ❌ Electron App Updates (NOT IMPLEMENTED)
|
||||
|
||||
**What it updates:** The Electron application itself (Auto Claude UI)
|
||||
|
||||
**Current State:**
|
||||
- ❌ No `electron-updater` dependency installed
|
||||
- ❌ No auto-update configuration in electron-builder
|
||||
- ❌ No update checking in main process
|
||||
- ❌ No UI for app update notifications
|
||||
- ❌ Users must manually download new releases from GitHub
|
||||
|
||||
**What users currently need to do:**
|
||||
1. Go to GitHub Releases page
|
||||
2. Download the appropriate installer (.dmg, .exe, .AppImage, etc.)
|
||||
3. Run the installer
|
||||
4. Manually replace the old app
|
||||
|
||||
---
|
||||
|
||||
## What Needs to Be Implemented
|
||||
|
||||
To enable automatic Electron app updates for non-technical users, we need to add:
|
||||
|
||||
### 1. Install electron-updater
|
||||
|
||||
```bash
|
||||
npm install electron-updater
|
||||
```
|
||||
|
||||
### 2. Configure electron-builder for Publishing
|
||||
|
||||
Add to `package.json` build config:
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "AndyMik90",
|
||||
"repo": "Auto-Claude"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Implement Auto-Update Logic in Main Process
|
||||
|
||||
Create `auto-claude-ui/src/main/app-updater.ts`:
|
||||
|
||||
```typescript
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
export function initializeAppUpdater(mainWindow: BrowserWindow) {
|
||||
// Configure update checking
|
||||
autoUpdater.autoDownload = false; // Let user decide
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Check for updates on launch (after 3 seconds)
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates();
|
||||
}, 3000);
|
||||
|
||||
// Check periodically (every 4 hours)
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates();
|
||||
}, 4 * 60 * 60 * 1000);
|
||||
|
||||
// Event handlers
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
mainWindow.webContents.send('app-update-available', {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
releaseDate: info.releaseDate
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
mainWindow.webContents.send('app-update-downloaded', {
|
||||
version: info.version
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
console.error('App update error:', error);
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
mainWindow.webContents.send('app-update-progress', {
|
||||
percent: progress.percent,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// IPC handlers
|
||||
export function registerAppUpdateHandlers() {
|
||||
ipcMain.handle('app-update-download', async () => {
|
||||
await autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update-install', () => {
|
||||
autoUpdater.quitAndInstall();
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update-check', async () => {
|
||||
return await autoUpdater.checkForUpdates();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add UI Notification Component
|
||||
|
||||
Create an update banner or modal in the renderer that:
|
||||
- Shows when app update is available
|
||||
- Displays version and release notes
|
||||
- Has "Download Update" button
|
||||
- Shows download progress
|
||||
- Has "Install and Restart" button after download
|
||||
|
||||
### 5. Update GitHub Release Workflow
|
||||
|
||||
Ensure GitHub releases are created with proper assets:
|
||||
- macOS: `.dmg` and `.zip` files + `latest-mac.yml`
|
||||
- Windows: `.exe` installer + `latest.yml`
|
||||
- Linux: `.AppImage` and `.deb` + `latest-linux.yml`
|
||||
|
||||
The `latest-*.yml` files are auto-generated by electron-builder and contain update metadata.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Option A: Full Auto-Update (Recommended)
|
||||
|
||||
**Pros:**
|
||||
- Best user experience
|
||||
- Automatic background downloads
|
||||
- One-click install
|
||||
- Industry standard
|
||||
|
||||
**Cons:**
|
||||
- Requires code signing certificates for production (macOS, Windows)
|
||||
- Without signing, users get security warnings
|
||||
|
||||
### Option B: Update Notification Only
|
||||
|
||||
**Pros:**
|
||||
- Simpler implementation
|
||||
- No code signing required
|
||||
- User downloads from GitHub (trusted source)
|
||||
|
||||
**Cons:**
|
||||
- Users still need to manually download and install
|
||||
- Less convenient than auto-update
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// Just notify, don't auto-download
|
||||
autoUpdater.autoDownload = false;
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
// Show notification with link to GitHub Releases
|
||||
mainWindow.webContents.send('app-update-available', {
|
||||
version: info.version,
|
||||
downloadUrl: `https://github.com/AndyMik90/Auto-Claude/releases/tag/v${info.version}`
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development vs Production
|
||||
|
||||
**Important:** `electron-updater` only works in **packaged apps**, not in development mode.
|
||||
|
||||
During development:
|
||||
```typescript
|
||||
if (app.isPackaged) {
|
||||
initializeAppUpdater(mainWindow);
|
||||
} else {
|
||||
console.log('[Dev] Auto-updater disabled in development mode');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Signing Requirements
|
||||
|
||||
For production auto-updates without security warnings:
|
||||
|
||||
### macOS
|
||||
- Requires Apple Developer account ($99/year)
|
||||
- Code signing certificate
|
||||
- Notarization with Apple
|
||||
|
||||
### Windows
|
||||
- Requires code signing certificate (~$200-400/year)
|
||||
- Without: Windows SmartScreen warnings
|
||||
|
||||
### Linux
|
||||
- No code signing required
|
||||
- Users may need to mark `.AppImage` as executable
|
||||
|
||||
---
|
||||
|
||||
## Testing Auto-Updates
|
||||
|
||||
1. **Local Testing:**
|
||||
- Build and package: `npm run package`
|
||||
- Create local update server or use GitHub Releases
|
||||
- Test with different versions
|
||||
|
||||
2. **GitHub Releases Testing:**
|
||||
- Create a draft release on GitHub
|
||||
- Publish with version tag (e.g., `v2.4.0`)
|
||||
- electron-builder automatically uploads assets
|
||||
- Test with previous version installed
|
||||
|
||||
---
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Phase 1: Add Update Notification** (Quick win)
|
||||
- Install `electron-updater`
|
||||
- Add basic update checking
|
||||
- Show notification with link to GitHub Releases
|
||||
- No auto-download, users download manually
|
||||
|
||||
2. **Phase 2: Enable Auto-Download** (Better UX)
|
||||
- Add download progress UI
|
||||
- Enable auto-download of updates
|
||||
- Add "Install and Restart" button
|
||||
|
||||
3. **Phase 3: Code Signing** (Production ready)
|
||||
- Acquire code signing certificates
|
||||
- Configure signing in electron-builder
|
||||
- Notarize macOS builds
|
||||
- Sign Windows builds
|
||||
|
||||
---
|
||||
|
||||
## Current Framework Update Flow (Already Working!)
|
||||
|
||||
For reference, here's how the existing Auto Claude framework updater works:
|
||||
|
||||
1. User opens **Settings > Advanced > Updates**
|
||||
2. App checks GitHub Releases API
|
||||
3. If update available, shows version + release notes
|
||||
4. User clicks "Download Update"
|
||||
5. Progress bar shows download status
|
||||
6. Update is extracted and applied to bundled source
|
||||
7. User configuration (.env) is preserved
|
||||
8. Done - no restart needed!
|
||||
|
||||
**This same UX could be replicated for app updates!**
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Component | Status | User Experience |
|
||||
|-----------|--------|-----------------|
|
||||
| Auto Claude Framework | ✅ Working | One-click update in Settings |
|
||||
| Electron App (UI) | ❌ Missing | Must download from GitHub manually |
|
||||
|
||||
**Recommendation:** Implement electron-updater with update notifications (Phase 1) as a quick win. This will enable non-technical users to update the app without using git or terminal commands.
|
||||
|
||||
**Estimated effort:**
|
||||
- Phase 1 (Notifications): 2-4 hours
|
||||
- Phase 2 (Auto-download): 2-3 hours
|
||||
- Phase 3 (Code signing): Varies by platform
|
||||
|
||||
The existing framework updater code provides an excellent reference for the UI implementation!
|
||||
@@ -1,139 +0,0 @@
|
||||
# Windows/Linux Source Path Detection Fix
|
||||
|
||||
## Problem
|
||||
|
||||
On Windows and Linux, when initializing a project, users were getting a "Source path not configured" error even though the `auto-claude` source directory exists. This error did not occur on macOS.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `detectAutoBuildSourcePath()` function in two files was using path resolution logic that worked on macOS in development mode but failed on Windows/Linux, especially in production/packaged builds. The function was trying to auto-detect where the Auto Claude framework source code (`auto-claude/` directory) is located, but the paths resolved differently across platforms.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Enhanced Path Detection Logic
|
||||
|
||||
Updated `detectAutoBuildSourcePath()` in two files:
|
||||
- `auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts`
|
||||
- `auto-claude-ui/src/main/ipc-handlers/project-handlers.ts`
|
||||
|
||||
**Key improvements:**
|
||||
|
||||
1. **Platform-aware path detection**: Separates development vs production mode using `is.dev` from `@electron-toolkit/utils`
|
||||
|
||||
2. **More comprehensive path checking**:
|
||||
- **Development mode**: Checks multiple relative paths from `__dirname`, `process.cwd()`, and parent directories
|
||||
- **Production mode**: Checks paths relative to `app.getAppPath()`, `process.resourcesPath`, and multiple levels up
|
||||
|
||||
3. **Debug logging**: Added detailed logging that can be enabled with `AUTO_CLAUDE_DEBUG=1` environment variable
|
||||
|
||||
4. **Better error messages**: Console warnings now guide users to enable debug mode if auto-detection fails
|
||||
|
||||
## Testing on Windows/Linux
|
||||
|
||||
### 1. Run with Debug Logging
|
||||
|
||||
Set the environment variable to see detailed path checking:
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
$env:AUTO_CLAUDE_DEBUG="1"
|
||||
.\Auto-Claude.exe
|
||||
```
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
```cmd
|
||||
set AUTO_CLAUDE_DEBUG=1
|
||||
Auto-Claude.exe
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
AUTO_CLAUDE_DEBUG=1 ./Auto-Claude
|
||||
```
|
||||
|
||||
### 2. Check Console Output
|
||||
|
||||
The debug output will show:
|
||||
- Current platform (win32/linux/darwin)
|
||||
- Whether running in dev or production mode
|
||||
- All paths being checked
|
||||
- Which paths exist and which don't
|
||||
- Whether auto-detection succeeded
|
||||
|
||||
Example debug output:
|
||||
```
|
||||
[detectAutoBuildSourcePath] Platform: win32
|
||||
[detectAutoBuildSourcePath] Is dev: false
|
||||
[detectAutoBuildSourcePath] __dirname: C:\Program Files\Auto-Claude\resources\app.asar\out\main
|
||||
[detectAutoBuildSourcePath] app.getAppPath(): C:\Program Files\Auto-Claude\resources\app.asar
|
||||
[detectAutoBuildSourcePath] process.cwd(): C:\Program Files\Auto-Claude
|
||||
[detectAutoBuildSourcePath] Checking paths: [...]
|
||||
[detectAutoBuildSourcePath] Checking C:\Program Files\auto-claude: ✗ not found
|
||||
[detectAutoBuildSourcePath] Checking C:\auto-claude: ✓ FOUND
|
||||
[detectAutoBuildSourcePath] Auto-detected source path: C:\auto-claude
|
||||
```
|
||||
|
||||
### 3. Manual Configuration (Fallback)
|
||||
|
||||
If auto-detection still fails, users can manually configure the path:
|
||||
|
||||
1. Open **App Settings** in Auto Claude UI
|
||||
2. Go to the **General** tab
|
||||
3. Set **Auto Claude Source Path** to the location of your `auto-claude` directory
|
||||
4. Click **Save**
|
||||
|
||||
Example paths:
|
||||
- Windows: `C:\Users\YourName\Projects\autonomous-coding\auto-claude`
|
||||
- Linux: `/home/yourname/projects/autonomous-coding/auto-claude`
|
||||
|
||||
## What Gets Checked
|
||||
|
||||
The function now checks these paths in order:
|
||||
|
||||
### Development Mode (`is.dev = true`):
|
||||
1. `__dirname/../../../auto-claude` - From out/main up 3 levels
|
||||
2. `__dirname/../../auto-claude` - From out/main up 2 levels
|
||||
3. `process.cwd()/auto-claude` - From current working directory
|
||||
4. `process.cwd()/../auto-claude` - From parent of cwd
|
||||
|
||||
### Production Mode (`is.dev = false`):
|
||||
1. `app.getAppPath()/../auto-claude` - Sibling to app
|
||||
2. `app.getAppPath()/../../auto-claude` - Up 2 from app
|
||||
3. `app.getAppPath()/../../../auto-claude` - Up 3 from app
|
||||
4. `process.resourcesPath/../auto-claude` - Relative to resources
|
||||
5. `process.resourcesPath/../../auto-claude` - Up 2 from resources
|
||||
|
||||
### All Modes:
|
||||
- `process.cwd()/auto-claude` - Last resort fallback
|
||||
|
||||
## Verification
|
||||
|
||||
For each path, the function checks:
|
||||
1. Does the directory exist?
|
||||
2. Does `VERSION` file exist inside it?
|
||||
|
||||
Both must be true for a path to be considered valid.
|
||||
|
||||
## Build Verification
|
||||
|
||||
The changes have been compiled and tested:
|
||||
```
|
||||
✓ Built successfully with no errors
|
||||
✓ All TypeScript files compiled
|
||||
✓ Electron app bundle created
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test on Windows**: Have Windows users test the updated build with `AUTO_CLAUDE_DEBUG=1`
|
||||
2. **Test on Linux**: Have Linux users test the updated build with `AUTO_CLAUDE_DEBUG=1`
|
||||
3. **Collect feedback**: If issues persist, the debug output will help identify the correct path patterns
|
||||
4. **Update documentation**: Add troubleshooting section to main README if needed
|
||||
|
||||
## Related Files
|
||||
|
||||
- `auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts`
|
||||
- `auto-claude-ui/src/main/ipc-handlers/project-handlers.ts`
|
||||
- `auto-claude-ui/src/main/project-initializer.ts`
|
||||
- `auto-claude-ui/src/renderer/App.tsx` (shows the error dialog)
|
||||
- `auto-claude-ui/src/renderer/components/Sidebar.tsx` (shows the error dialog)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Auto Claude UI Environment Variables
|
||||
# Copy this file to .env and set your values
|
||||
|
||||
# ============================================
|
||||
# DEBUG SETTINGS
|
||||
# ============================================
|
||||
|
||||
# Enable debug logging across the entire application
|
||||
# When enabled, you'll see detailed console logs for:
|
||||
# - Ideation and roadmap generation
|
||||
# - IPC communication between processes
|
||||
# - Store state updates
|
||||
# - Changelog generation and project initialization
|
||||
# - GitHub OAuth flow
|
||||
# Usage: Set to 'true' before starting the app
|
||||
# DEBUG=true
|
||||
|
||||
# Enable debug logging for the auto-updater only
|
||||
# Shows detailed information about app update checks and downloads
|
||||
# DEBUG_UPDATER=true
|
||||
|
||||
# ============================================
|
||||
# HOW TO USE
|
||||
# ============================================
|
||||
|
||||
# Option 1: Set in your shell before starting the app
|
||||
# DEBUG=true npm start
|
||||
#
|
||||
# Option 2: Export in your shell profile (~/.bashrc, ~/.zshrc, etc.)
|
||||
# export DEBUG=true
|
||||
#
|
||||
# Option 3: Create a .env file in this directory (auto-claude-ui/)
|
||||
# Copy this file: cp .env.example .env
|
||||
# Then uncomment and set the variables you need
|
||||
#
|
||||
# Note: The Electron app will read these from process.env
|
||||
# The Python backend (auto-claude) has its own .env file
|
||||
|
||||
# ============================================
|
||||
# DEVELOPMENT
|
||||
# ============================================
|
||||
|
||||
# Node environment (automatically set by npm scripts)
|
||||
# NODE_ENV=development
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
index: resolve(__dirname, 'src/main/index.ts')
|
||||
},
|
||||
// Only node-pty needs to be external (native module rebuilt by electron-builder)
|
||||
external: ['node-pty']
|
||||
external: ['@lydell/node-pty']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+29
-31
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.6.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -150,7 +150,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -536,7 +535,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -560,7 +558,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -600,7 +597,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -995,6 +991,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1016,6 +1013,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -3930,7 +3928,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -4127,7 +4126,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -4138,7 +4136,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4230,7 +4227,6 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@@ -4630,8 +4626,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
@@ -4653,7 +4648,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4714,7 +4708,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -4887,6 +4880,7 @@
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
@@ -5271,7 +5265,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5951,7 +5944,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -6295,7 +6289,6 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -6353,7 +6346,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
@@ -6429,7 +6423,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
@@ -6558,6 +6551,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -6578,6 +6572,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -6593,6 +6588,7 @@
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
@@ -6603,6 +6599,7 @@
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
@@ -6972,7 +6969,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8992,7 +8988,6 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -9936,6 +9931,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -11775,7 +11771,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11873,7 +11868,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11910,6 +11904,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -11927,6 +11922,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -11947,6 +11943,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -11962,6 +11959,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -11974,7 +11972,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "2.0.1",
|
||||
@@ -12078,7 +12077,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12088,7 +12086,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -13405,8 +13402,7 @@
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
@@ -13463,6 +13459,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -13489,6 +13486,7 @@
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
@@ -13510,6 +13508,7 @@
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -13523,6 +13522,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -13537,6 +13537,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -13853,7 +13854,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14194,7 +14194,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -15228,7 +15227,6 @@
|
||||
"integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.6.5",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Auto Claude Team",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"postinstall": "electron-rebuild",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:mcp": "electron-vite dev -- --remote-debugging-port=9222",
|
||||
"build": "electron-vite build",
|
||||
"start": "electron .",
|
||||
"start:mcp": "electron . --remote-debugging-port=9222",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "electron-vite build && electron-builder",
|
||||
"package:mac": "electron-vite build && electron-builder --mac",
|
||||
@@ -30,6 +32,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lydell/node-pty": "^1.1.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
@@ -59,7 +62,6 @@
|
||||
"ioredis": "^5.8.2",
|
||||
"lucide-react": "^0.560.0",
|
||||
"motion": "^12.23.26",
|
||||
"node-pty": "^1.1.0-beta42",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -104,12 +106,13 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"electron-builder-squirrel-windows": "^26.0.12",
|
||||
"dmg-builder": "^26.0.12"
|
||||
"dmg-builder": "^26.0.12",
|
||||
"node-pty": "npm:@lydell/node-pty@^1.1.0"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild",
|
||||
"node-pty"
|
||||
"electron-winstaller",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
@@ -132,12 +135,24 @@
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "node_modules/node-pty",
|
||||
"to": "node_modules/node-pty"
|
||||
"from": "node_modules/@lydell/node-pty",
|
||||
"to": "node_modules/@lydell/node-pty"
|
||||
},
|
||||
{
|
||||
"from": "resources/icon.ico",
|
||||
"to": "icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "../auto-claude",
|
||||
"to": "auto-claude",
|
||||
"filter": [
|
||||
"!**/.git",
|
||||
"!**/__pycache__",
|
||||
"!**/*.pyc",
|
||||
"!**/specs",
|
||||
"!**/.venv",
|
||||
"!**/.env"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -168,5 +183,6 @@
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --fix"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.26.1+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa"
|
||||
}
|
||||
|
||||
Generated
+645
-335
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Download prebuilt native modules for Windows
|
||||
*
|
||||
* This script downloads pre-compiled node-pty binaries from GitHub releases,
|
||||
* eliminating the need for Visual Studio Build Tools on Windows.
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const GITHUB_REPO = 'AndyMik90/Auto-Claude';
|
||||
const GITHUB_API = 'https://api.github.com';
|
||||
|
||||
/**
|
||||
* Get the Electron ABI version for the installed Electron
|
||||
*/
|
||||
function getElectronAbi() {
|
||||
try {
|
||||
// Try to get from electron-abi package
|
||||
const result = execSync('npx electron-abi', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
// Fallback: read from electron package
|
||||
try {
|
||||
const electronPkg = require('electron/package.json');
|
||||
const version = electronPkg.version;
|
||||
// Electron 39.x = ABI 140
|
||||
const majorVersion = parseInt(version.split('.')[0], 10);
|
||||
// This is a rough mapping, electron-abi is more accurate
|
||||
const abiMap = {
|
||||
39: 140,
|
||||
38: 139,
|
||||
37: 136,
|
||||
36: 135,
|
||||
35: 134,
|
||||
34: 132,
|
||||
33: 131,
|
||||
32: 130,
|
||||
31: 129,
|
||||
30: 128,
|
||||
};
|
||||
return abiMap[majorVersion] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest release from GitHub
|
||||
*/
|
||||
function getLatestRelease() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'api.github.com',
|
||||
path: `/repos/${GITHUB_REPO}/releases/latest`,
|
||||
headers: {
|
||||
'User-Agent': 'Auto-Claude-Installer',
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
};
|
||||
|
||||
https
|
||||
.get(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(JSON.parse(data));
|
||||
} else if (res.statusCode === 404) {
|
||||
resolve(null); // No releases yet
|
||||
} else {
|
||||
reject(new Error(`GitHub API returned ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find prebuild asset in release
|
||||
*/
|
||||
function findPrebuildAsset(release, arch, electronAbi) {
|
||||
if (!release || !release.assets) return null;
|
||||
|
||||
const assetName = `node-pty-win32-${arch}-electron-${electronAbi}.zip`;
|
||||
return release.assets.find((asset) => asset.name === assetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from URL
|
||||
*/
|
||||
function downloadFile(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const request = (url) => {
|
||||
https
|
||||
.get(url, { headers: { 'User-Agent': 'Auto-Claude-Installer' } }, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
// Follow redirect
|
||||
request(res.headers.location);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Download failed with status ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
res.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.on('error', (err) => {
|
||||
fs.unlink(destPath, () => {}); // Delete partial file
|
||||
reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
request(url);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract zip file (using built-in tools)
|
||||
*/
|
||||
function extractZip(zipPath, destDir) {
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Use PowerShell on Windows
|
||||
execSync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to download and install prebuilds
|
||||
*/
|
||||
async function downloadPrebuilds() {
|
||||
const arch = process.arch; // x64 or arm64
|
||||
const electronAbi = getElectronAbi();
|
||||
|
||||
if (!electronAbi) {
|
||||
console.log('[prebuilds] Could not determine Electron ABI version');
|
||||
return { success: false, reason: 'unknown-abi' };
|
||||
}
|
||||
|
||||
console.log(`[prebuilds] Looking for prebuilds: win32-${arch}, Electron ABI ${electronAbi}`);
|
||||
|
||||
// Check for prebuilds in GitHub releases
|
||||
let release;
|
||||
try {
|
||||
release = await getLatestRelease();
|
||||
} catch (err) {
|
||||
console.log(`[prebuilds] Could not fetch releases: ${err.message}`);
|
||||
return { success: false, reason: 'fetch-failed' };
|
||||
}
|
||||
|
||||
if (!release) {
|
||||
console.log('[prebuilds] No releases found');
|
||||
return { success: false, reason: 'no-releases' };
|
||||
}
|
||||
|
||||
const asset = findPrebuildAsset(release, arch, electronAbi);
|
||||
if (!asset) {
|
||||
console.log(`[prebuilds] No prebuild found for win32-${arch}-electron-${electronAbi}`);
|
||||
console.log('[prebuilds] Available assets:', release.assets?.map((a) => a.name).join(', ') || 'none');
|
||||
return { success: false, reason: 'no-matching-prebuild' };
|
||||
}
|
||||
|
||||
console.log(`[prebuilds] Found prebuild: ${asset.name}`);
|
||||
|
||||
// Download the prebuild
|
||||
const tempDir = path.join(__dirname, '..', '.prebuild-temp');
|
||||
const zipPath = path.join(tempDir, asset.name);
|
||||
const nodePtyDir = path.join(__dirname, '..', 'node_modules', 'node-pty');
|
||||
const buildDir = path.join(nodePtyDir, 'build', 'Release');
|
||||
|
||||
try {
|
||||
// Create temp directory
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
console.log(`[prebuilds] Downloading ${asset.name}...`);
|
||||
await downloadFile(asset.browser_download_url, zipPath);
|
||||
|
||||
console.log('[prebuilds] Extracting...');
|
||||
extractZip(zipPath, tempDir);
|
||||
|
||||
// Find the extracted prebuild directory
|
||||
const extractedDir = path.join(tempDir, 'prebuilds', `win32-${arch}-electron-${electronAbi}`);
|
||||
|
||||
if (!fs.existsSync(extractedDir)) {
|
||||
throw new Error(`Extracted directory not found: ${extractedDir}`);
|
||||
}
|
||||
|
||||
// Ensure build/Release directory exists
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
// Copy files to node_modules/node-pty/build/Release
|
||||
const files = fs.readdirSync(extractedDir);
|
||||
for (const file of files) {
|
||||
const src = path.join(extractedDir, file);
|
||||
const dest = path.join(buildDir, file);
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log(`[prebuilds] Installed: ${file}`);
|
||||
}
|
||||
|
||||
// Cleanup temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
console.log('[prebuilds] Successfully installed prebuilt binaries!');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Cleanup on error
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`[prebuilds] Download/extract failed: ${err.message}`);
|
||||
return { success: false, reason: 'install-failed', error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use by postinstall
|
||||
module.exports = { downloadPrebuilds, getElectronAbi };
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
downloadPrebuilds()
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[prebuilds] Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-install script for Auto Claude UI
|
||||
*
|
||||
* On Windows:
|
||||
* 1. Try to download prebuilt node-pty binaries from GitHub releases
|
||||
* 2. Fall back to electron-rebuild if prebuilds aren't available
|
||||
* 3. Show helpful error message if compilation fails
|
||||
*
|
||||
* On macOS/Linux:
|
||||
* 1. Run electron-rebuild (compilers are typically available)
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
const WINDOWS_BUILD_TOOLS_HELP = `
|
||||
================================================================================
|
||||
VISUAL STUDIO BUILD TOOLS REQUIRED
|
||||
================================================================================
|
||||
|
||||
Prebuilt binaries weren't available for your Electron version, and compilation
|
||||
requires Visual Studio Build Tools.
|
||||
|
||||
To install:
|
||||
|
||||
1. Download Visual Studio Build Tools 2022:
|
||||
https://visualstudio.microsoft.com/visual-cpp-build-tools/
|
||||
|
||||
2. Run installer and select:
|
||||
- "Desktop development with C++" workload
|
||||
|
||||
3. In "Individual Components", also select:
|
||||
- "MSVC v143 - VS 2022 C++ x64/x86 Spectre-mitigated libs"
|
||||
|
||||
4. Restart your terminal and run: npm install
|
||||
|
||||
================================================================================
|
||||
`;
|
||||
|
||||
/**
|
||||
* Run electron-rebuild
|
||||
*/
|
||||
function runElectronRebuild() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const npx = isWindows ? 'npx.cmd' : 'npx';
|
||||
const child = spawn(npx, ['electron-rebuild'], {
|
||||
stdio: 'inherit',
|
||||
shell: isWindows,
|
||||
cwd: path.join(__dirname, '..'),
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
reject(new Error(`electron-rebuild exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node-pty is already built
|
||||
*/
|
||||
function isNodePtyBuilt() {
|
||||
const buildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'build', 'Release');
|
||||
if (!fs.existsSync(buildDir)) return false;
|
||||
|
||||
// Check for the main .node file
|
||||
const files = fs.readdirSync(buildDir);
|
||||
return files.some((f) => f.endsWith('.node'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Main postinstall logic
|
||||
*/
|
||||
async function main() {
|
||||
console.log('[postinstall] Setting up native modules for Electron...\n');
|
||||
|
||||
// If node-pty is already built (e.g., from a previous successful install), skip
|
||||
if (isNodePtyBuilt()) {
|
||||
console.log('[postinstall] Native modules already built, skipping rebuild.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
// On Windows, try prebuilds first
|
||||
console.log('[postinstall] Windows detected - checking for prebuilt binaries...\n');
|
||||
|
||||
try {
|
||||
// Dynamic import to handle case where the script doesn't exist yet
|
||||
const { downloadPrebuilds } = require('./download-prebuilds.js');
|
||||
const result = await downloadPrebuilds();
|
||||
|
||||
if (result.success) {
|
||||
console.log('\n[postinstall] Successfully installed prebuilt binaries!');
|
||||
console.log('[postinstall] No Visual Studio Build Tools required.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n[postinstall] Prebuilds not available (${result.reason})`);
|
||||
console.log('[postinstall] Falling back to electron-rebuild...\n');
|
||||
} catch (err) {
|
||||
console.log('[postinstall] Could not check for prebuilds:', err.message);
|
||||
console.log('[postinstall] Falling back to electron-rebuild...\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Run electron-rebuild
|
||||
try {
|
||||
console.log('[postinstall] Running electron-rebuild...\n');
|
||||
await runElectronRebuild();
|
||||
console.log('\n[postinstall] Native modules built successfully!');
|
||||
} catch (error) {
|
||||
console.error('\n[postinstall] Failed to build native modules.\n');
|
||||
|
||||
if (isWindows) {
|
||||
console.error(WINDOWS_BUILD_TOOLS_HELP);
|
||||
} else {
|
||||
console.error('Error:', error.message);
|
||||
console.error('\nYou may need to install build tools for your platform:');
|
||||
console.error(' macOS: xcode-select --install');
|
||||
console.error(' Linux: sudo apt-get install build-essential\n');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[postinstall] Unexpected error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -29,6 +29,14 @@ vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(() => mockProcess)
|
||||
}));
|
||||
|
||||
// Mock claude-profile-manager to bypass auth checks in tests
|
||||
vi.mock('../../main/claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: () => ({
|
||||
hasValidAuth: () => true,
|
||||
getActiveProfile: () => ({ profileId: 'default', profileName: 'Default' })
|
||||
})
|
||||
}));
|
||||
|
||||
// Auto-claude source path (for getAutoBuildSourcePath to find)
|
||||
const AUTO_CLAUDE_SOURCE = path.join(TEST_DIR, 'auto-claude-source');
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ vi.mock('@electron-toolkit/utils', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock version-manager to return a predictable version
|
||||
vi.mock('../updater/version-manager', () => ({
|
||||
getEffectiveVersion: vi.fn(() => '0.1.0'),
|
||||
getBundledVersion: vi.fn(() => '0.1.0'),
|
||||
parseVersionFromTag: vi.fn((tag: string) => tag.replace('v', '')),
|
||||
compareVersions: vi.fn(() => 0)
|
||||
}));
|
||||
|
||||
// Mock modules before importing
|
||||
vi.mock('electron', () => {
|
||||
const mockIpcMain = new (class extends EventEmitter {
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Unit tests for rate limit and auth failure detection
|
||||
* Tests detection patterns for rate limiting and authentication failures
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock the claude-profile-manager before importing
|
||||
vi.mock('../claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: vi.fn(() => ({
|
||||
getActiveProfile: vi.fn(() => ({
|
||||
id: 'test-profile-id',
|
||||
name: 'Test Profile',
|
||||
isDefault: true
|
||||
})),
|
||||
getProfile: vi.fn((id: string) => ({
|
||||
id,
|
||||
name: 'Test Profile',
|
||||
isDefault: true
|
||||
})),
|
||||
getBestAvailableProfile: vi.fn(() => null),
|
||||
recordRateLimitEvent: vi.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('Rate Limit Detector', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectRateLimit', () => {
|
||||
it('should detect rate limit with reset time', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am (Europe/Oslo)';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
expect(result.resetTime).toBe('Dec 17 at 6am (Europe/Oslo)');
|
||||
expect(result.limitType).toBe('weekly');
|
||||
});
|
||||
|
||||
it('should detect rate limit with bullet character', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached • resets 11:59pm';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
expect(result.resetTime).toBe('11:59pm');
|
||||
expect(result.limitType).toBe('session');
|
||||
});
|
||||
|
||||
it('should detect secondary rate limit indicators', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const testCases = [
|
||||
'rate limit exceeded',
|
||||
'usage limit reached',
|
||||
'You have exceeded your limit',
|
||||
'too many requests'
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectRateLimit(output);
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit output', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty output', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectRateLimit('');
|
||||
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateLimitError', () => {
|
||||
it('should return true for rate limit errors', async () => {
|
||||
const { isRateLimitError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isRateLimitError('Limit reached · resets Dec 17 at 6am')).toBe(true);
|
||||
expect(isRateLimitError('rate limit exceeded')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit errors', async () => {
|
||||
const { isRateLimitError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isRateLimitError('authentication required')).toBe(false);
|
||||
expect(isRateLimitError('Task completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractResetTime', () => {
|
||||
it('should extract reset time from rate limit message', async () => {
|
||||
const { extractResetTime } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am (Europe/Oslo)';
|
||||
const resetTime = extractResetTime(output);
|
||||
|
||||
expect(resetTime).toBe('Dec 17 at 6am (Europe/Oslo)');
|
||||
});
|
||||
|
||||
it('should return null for non-rate-limit output', async () => {
|
||||
const { extractResetTime } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const resetTime = extractResetTime(output);
|
||||
|
||||
expect(resetTime).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth Failure Detection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectAuthFailure', () => {
|
||||
it('should detect "authentication required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: authentication required';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
expect(result.message).toContain('authentication required');
|
||||
});
|
||||
|
||||
it('should detect "authentication is required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Authentication is required to proceed';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "not authenticated" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: not authenticated';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "not yet authenticated" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'You are not yet authenticated';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "login required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Login required';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "oauth token invalid" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token is invalid';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "oauth token expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should detect "oauth token missing" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token missing';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "unauthorized" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: Unauthorized';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "please log in" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Please log in to continue';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
// "please log in" doesn't contain 'required' keyword, so classified as 'unknown'
|
||||
expect(result.failureType).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect "please authenticate" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Please authenticate before proceeding';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
// "please authenticate" doesn't contain 'required' keyword, so classified as 'unknown'
|
||||
expect(result.failureType).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect "invalid credentials" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Invalid credentials provided';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "invalid token" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Invalid token';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "auth failed" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Auth failed';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "authentication error" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Authentication error occurred';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "session expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Your session expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should detect "access denied" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Access denied';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "permission denied" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Permission denied';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "401 unauthorized" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'HTTP 401 Unauthorized';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "credentials missing" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Credentials are missing';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "credentials expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Credentials expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should return false for rate limit errors (not auth failure)', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for normal output', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty output', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('');
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should include profile ID in result', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required', 'custom-profile');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.profileId).toBe('custom-profile');
|
||||
});
|
||||
|
||||
it('should use active profile ID when not specified', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.profileId).toBe('test-profile-id');
|
||||
});
|
||||
|
||||
it('should include original error in result', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: authentication required for this action';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.originalError).toBe(output);
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for missing auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('Settings');
|
||||
expect(result.message).toContain('Claude Profiles');
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for expired auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('session expired');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('expired');
|
||||
expect(result.message).toContain('re-authenticate');
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for invalid auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('unauthorized');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('Invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthFailureError', () => {
|
||||
it('should return true for auth failure errors', async () => {
|
||||
const { isAuthFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isAuthFailureError('authentication required')).toBe(true);
|
||||
expect(isAuthFailureError('not authenticated')).toBe(true);
|
||||
expect(isAuthFailureError('unauthorized')).toBe(true);
|
||||
expect(isAuthFailureError('invalid token')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-auth-failure errors', async () => {
|
||||
const { isAuthFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isAuthFailureError('Limit reached · resets Dec 17')).toBe(false);
|
||||
expect(isAuthFailureError('Task completed')).toBe(false);
|
||||
expect(isAuthFailureError('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth failure does not match rate limit patterns', () => {
|
||||
it('should not detect auth failure as rate limit', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const authErrors = [
|
||||
'authentication required',
|
||||
'not authenticated',
|
||||
'unauthorized',
|
||||
'invalid token',
|
||||
'session expired',
|
||||
'please log in'
|
||||
];
|
||||
|
||||
for (const error of authErrors) {
|
||||
const result = detectRateLimit(error);
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not detect rate limit as auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const rateLimitErrors = [
|
||||
'Limit reached · resets Dec 17 at 6am',
|
||||
'rate limit exceeded',
|
||||
'too many requests',
|
||||
'usage limit reached'
|
||||
];
|
||||
|
||||
for (const error of rateLimitErrors) {
|
||||
const result = detectAuthFailure(error);
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiline output with auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = `Starting task...
|
||||
Processing...
|
||||
Error: authentication required
|
||||
Please authenticate and try again.`;
|
||||
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const testCases = [
|
||||
'AUTHENTICATION REQUIRED',
|
||||
'Authentication Required',
|
||||
'UNAUTHORIZED',
|
||||
'Unauthorized',
|
||||
'NOT AUTHENTICATED',
|
||||
'Not Authenticated'
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectAuthFailure(output);
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle partial matches correctly', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
// Should NOT match - word is part of a different context
|
||||
const falsePositives = [
|
||||
'The authenticated user can proceed', // has 'authenticated' but not an error
|
||||
'Authorization header set correctly' // different word
|
||||
];
|
||||
|
||||
// Note: Some false positives may still match due to pattern design
|
||||
// The patterns are intentionally broad to catch errors
|
||||
for (const output of falsePositives) {
|
||||
const result = detectAuthFailure(output);
|
||||
// Just verify it runs without error - actual match depends on pattern design
|
||||
expect(typeof result.isAuthFailure).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle JSON error responses', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = '{"error": "unauthorized", "message": "Please authenticate"}';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle error stack traces with auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = `Error: authentication required
|
||||
at validateToken (/app/auth.js:42)
|
||||
at processRequest (/app/handler.js:15)
|
||||
at main (/app/index.js:8)`;
|
||||
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,13 @@ import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { AgentQueueManager } from './agent-queue';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import {
|
||||
SpecCreationMetadata,
|
||||
TaskExecutionOptions,
|
||||
IdeationConfig
|
||||
RoadmapConfig
|
||||
} from './types';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Main AgentManager - orchestrates agent process lifecycle
|
||||
@@ -89,6 +91,13 @@ export class AgentManager extends EventEmitter {
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata
|
||||
): void {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
this.emit('error', taskId, 'Claude authentication required. Please authenticate in Settings > Claude Profiles before starting tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
@@ -120,6 +129,20 @@ export class AgentManager extends EventEmitter {
|
||||
args.push('--auto-approve');
|
||||
}
|
||||
|
||||
// Pass model and thinking level configuration
|
||||
// For auto profile, use phase-specific config; otherwise use single model/thinking
|
||||
if (metadata?.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) {
|
||||
// Pass the spec phase model and thinking level to spec_runner
|
||||
args.push('--model', metadata.phaseModels.spec);
|
||||
args.push('--thinking-level', metadata.phaseThinking.spec);
|
||||
} else if (metadata?.model) {
|
||||
// Non-auto profile: use single model and thinking level
|
||||
args.push('--model', metadata.model);
|
||||
if (metadata.thinkingLevel) {
|
||||
args.push('--thinking-level', metadata.thinkingLevel);
|
||||
}
|
||||
}
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata);
|
||||
|
||||
@@ -136,6 +159,13 @@ export class AgentManager extends EventEmitter {
|
||||
specId: string,
|
||||
options: TaskExecutionOptions = {}
|
||||
): void {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
this.emit('error', taskId, 'Claude authentication required. Please authenticate in Settings > Claude Profiles before starting tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
@@ -168,6 +198,8 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
// Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent
|
||||
// The options.parallel and options.workers are kept for future use or logging purposes
|
||||
// Note: Model configuration is read from task_metadata.json by the Python scripts,
|
||||
// which allows per-phase configuration for planner, coder, and QA phases
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false);
|
||||
@@ -212,9 +244,11 @@ export class AgentManager extends EventEmitter {
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
refresh: boolean = false,
|
||||
enableCompetitorAnalysis: boolean = false
|
||||
enableCompetitorAnalysis: boolean = false,
|
||||
refreshCompetitorAnalysis: boolean = false,
|
||||
config?: RoadmapConfig
|
||||
): void {
|
||||
this.queueManager.startRoadmapGeneration(projectId, projectPath, refresh, enableCompetitorAnalysis);
|
||||
this.queueManager.startRoadmapGeneration(projectId, projectPath, refresh, enableCompetitorAnalysis, refreshCompetitorAnalysis, config);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,6 +284,20 @@ export class AgentManager extends EventEmitter {
|
||||
return this.queueManager.isIdeationRunning(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop roadmap generation for a project
|
||||
*/
|
||||
stopRoadmap(projectId: string): boolean {
|
||||
return this.queueManager.stopRoadmap(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if roadmap is running for a project
|
||||
*/
|
||||
isRoadmapRunning(projectId: string): boolean {
|
||||
return this.queueManager.isRoadmapRunning(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all running processes
|
||||
*/
|
||||
|
||||
@@ -6,9 +6,10 @@ import { EventEmitter } from 'events';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { ProcessType, ExecutionProgressData } from './types';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
|
||||
import { projectStore } from '../project-store';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { findPythonCommand, parsePythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Process spawning and lifecycle management
|
||||
@@ -17,7 +18,8 @@ export class AgentProcessManager {
|
||||
private state: AgentState;
|
||||
private events: AgentEvents;
|
||||
private emitter: EventEmitter;
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
constructor(state: AgentState, events: AgentEvents, emitter: EventEmitter) {
|
||||
@@ -161,7 +163,9 @@ export class AgentProcessManager {
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
const childProcess = spawn(this.pythonPath, args, {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -237,6 +241,10 @@ export class AgentProcessManager {
|
||||
const log = data.toString('utf8');
|
||||
this.emitter.emit('log', taskId, log);
|
||||
processLog(log);
|
||||
// Print to console when DEBUG is enabled (visible in pnpm dev terminal)
|
||||
if (['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '')) {
|
||||
console.log(`[Agent:${taskId}] ${log.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr - explicitly decode as UTF-8 for cross-platform Unicode support
|
||||
@@ -246,6 +254,10 @@ export class AgentProcessManager {
|
||||
// so we treat it as log, not error
|
||||
this.emitter.emit('log', taskId, log);
|
||||
processLog(log);
|
||||
// Print to console when DEBUG is enabled (visible in pnpm dev terminal)
|
||||
if (['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '')) {
|
||||
console.log(`[Agent:${taskId}] ${log.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
@@ -300,6 +312,17 @@ export class AgentProcessManager {
|
||||
taskId
|
||||
});
|
||||
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
|
||||
} else {
|
||||
// Not rate limited - check for authentication failure
|
||||
const authFailureDetection = detectAuthFailure(allOutput);
|
||||
if (authFailureDetection.isAuthFailure) {
|
||||
this.emitter.emit('auth-failure', taskId, {
|
||||
profileId: authFailureDetection.profileId,
|
||||
failureType: authFailureDetection.failureType,
|
||||
message: authFailureDetection.message,
|
||||
originalError: authFailureDetection.originalError
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@ import { EventEmitter } from 'events';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { IdeationConfig } from './types';
|
||||
import { RoadmapConfig } from './types';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
import { MODEL_ID_MAP } from '../../shared/constants';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Queue management for ideation and roadmap generation
|
||||
@@ -31,23 +35,40 @@ export class AgentQueueManager {
|
||||
|
||||
/**
|
||||
* Start roadmap generation process
|
||||
*
|
||||
* @param refreshCompetitorAnalysis - Force refresh competitor analysis even if it exists.
|
||||
* This allows refreshing competitor data independently of the general roadmap refresh.
|
||||
* Use when user explicitly wants new competitor research.
|
||||
*/
|
||||
startRoadmapGeneration(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
refresh: boolean = false,
|
||||
enableCompetitorAnalysis: boolean = false
|
||||
enableCompetitorAnalysis: boolean = false,
|
||||
refreshCompetitorAnalysis: boolean = false,
|
||||
config?: RoadmapConfig
|
||||
): void {
|
||||
debugLog('[Agent Queue] Starting roadmap generation:', {
|
||||
projectId,
|
||||
projectPath,
|
||||
refresh,
|
||||
enableCompetitorAnalysis,
|
||||
refreshCompetitorAnalysis,
|
||||
config
|
||||
});
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
debugError('[Agent Queue] Auto-build source path not found');
|
||||
this.emitter.emit('roadmap-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const roadmapRunnerPath = path.join(autoBuildSource, 'roadmap_runner.py');
|
||||
const roadmapRunnerPath = path.join(autoBuildSource, 'runners', 'roadmap_runner.py');
|
||||
|
||||
if (!existsSync(roadmapRunnerPath)) {
|
||||
debugError('[Agent Queue] Roadmap runner not found at:', roadmapRunnerPath);
|
||||
this.emitter.emit('roadmap-error', projectId, `Roadmap runner not found at: ${roadmapRunnerPath}`);
|
||||
return;
|
||||
}
|
||||
@@ -63,6 +84,22 @@ export class AgentQueueManager {
|
||||
args.push('--competitor-analysis');
|
||||
}
|
||||
|
||||
// Add refresh competitor analysis flag if user wants fresh competitor data
|
||||
if (refreshCompetitorAnalysis) {
|
||||
args.push('--refresh-competitor-analysis');
|
||||
}
|
||||
|
||||
// Add model and thinking level from config
|
||||
if (config?.model) {
|
||||
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
|
||||
args.push('--model', modelId);
|
||||
}
|
||||
if (config?.thinkingLevel) {
|
||||
args.push('--thinking-level', config.thinkingLevel);
|
||||
}
|
||||
|
||||
debugLog('[Agent Queue] Spawning roadmap process with args:', args);
|
||||
|
||||
// Use projectId as taskId for roadmap operations
|
||||
this.spawnRoadmapProcess(projectId, projectPath, args);
|
||||
}
|
||||
@@ -76,16 +113,25 @@ export class AgentQueueManager {
|
||||
config: IdeationConfig,
|
||||
refresh: boolean = false
|
||||
): void {
|
||||
debugLog('[Agent Queue] Starting ideation generation:', {
|
||||
projectId,
|
||||
projectPath,
|
||||
config,
|
||||
refresh
|
||||
});
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
debugError('[Agent Queue] Auto-build source path not found');
|
||||
this.emitter.emit('ideation-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ideationRunnerPath = path.join(autoBuildSource, 'ideation_runner.py');
|
||||
const ideationRunnerPath = path.join(autoBuildSource, 'runners', 'ideation_runner.py');
|
||||
|
||||
if (!existsSync(ideationRunnerPath)) {
|
||||
debugError('[Agent Queue] Ideation runner not found at:', ideationRunnerPath);
|
||||
this.emitter.emit('ideation-error', projectId, `Ideation runner not found at: ${ideationRunnerPath}`);
|
||||
return;
|
||||
}
|
||||
@@ -119,6 +165,17 @@ export class AgentQueueManager {
|
||||
args.push('--append');
|
||||
}
|
||||
|
||||
// Add model and thinking level from config
|
||||
if (config.model) {
|
||||
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
|
||||
args.push('--model', modelId);
|
||||
}
|
||||
if (config.thinkingLevel) {
|
||||
args.push('--thinking-level', config.thinkingLevel);
|
||||
}
|
||||
|
||||
debugLog('[Agent Queue] Spawning ideation process with args:', args);
|
||||
|
||||
// Use projectId as taskId for ideation operations
|
||||
this.spawnIdeationProcess(projectId, projectPath, args);
|
||||
}
|
||||
@@ -131,11 +188,17 @@ export class AgentQueueManager {
|
||||
projectPath: string,
|
||||
args: string[]
|
||||
): void {
|
||||
debugLog('[Agent Queue] Spawning ideation process:', { projectId, projectPath });
|
||||
|
||||
// Kill existing process for this project if any
|
||||
this.processManager.killProcess(projectId);
|
||||
const wasKilled = this.processManager.killProcess(projectId);
|
||||
if (wasKilled) {
|
||||
debugLog('[Agent Queue] Killed existing process for project:', projectId);
|
||||
}
|
||||
|
||||
// Generate unique spawn ID for this process instance
|
||||
const spawnId = this.state.generateSpawnId();
|
||||
debugLog('[Agent Queue] Generated spawn ID:', spawnId);
|
||||
|
||||
// Run from auto-claude source directory so imports work correctly
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
@@ -144,22 +207,44 @@ export class AgentQueueManager {
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
// Get Python path from process manager (uses venv if configured)
|
||||
const pythonPath = this.processManager.getPythonPath();
|
||||
|
||||
const childProcess = spawn(pythonPath, args, {
|
||||
// Build final environment with proper precedence:
|
||||
// 1. process.env (system)
|
||||
// 2. combinedEnv (auto-claude/.env for CLI usage)
|
||||
// 3. profileEnv (Electron app OAuth token - highest priority)
|
||||
// 4. Our specific overrides
|
||||
const finalEnv = {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONPATH: autoBuildSource || '', // Allow imports from auto-claude directory
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
};
|
||||
|
||||
// Debug: Show OAuth token source
|
||||
const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN']
|
||||
? 'Electron app profile'
|
||||
: (combinedEnv['CLAUDE_CODE_OAUTH_TOKEN'] ? 'auto-claude/.env' : 'not found');
|
||||
const oauthToken = (finalEnv as Record<string, string | undefined>)['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
const hasToken = !!oauthToken;
|
||||
debugLog('[Agent Queue] OAuth token status:', {
|
||||
source: tokenSource,
|
||||
hasToken,
|
||||
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
|
||||
});
|
||||
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
}
|
||||
env: finalEnv
|
||||
});
|
||||
|
||||
this.state.addProcess(projectId, {
|
||||
@@ -167,7 +252,8 @@ export class AgentQueueManager {
|
||||
process: childProcess,
|
||||
startedAt: new Date(),
|
||||
projectPath, // Store project path for loading session on completion
|
||||
spawnId
|
||||
spawnId,
|
||||
queueProcessType: 'ideation'
|
||||
});
|
||||
|
||||
// Track progress through output
|
||||
@@ -206,6 +292,13 @@ export class AgentQueueManager {
|
||||
const [, ideationType, ideasCount] = typeCompleteMatch;
|
||||
completedTypes.add(ideationType);
|
||||
|
||||
debugLog('[Agent Queue] Ideation type completed:', {
|
||||
projectId,
|
||||
ideationType,
|
||||
ideasCount: parseInt(ideasCount, 10),
|
||||
totalCompleted: completedTypes.size
|
||||
});
|
||||
|
||||
// Emit event for UI to load this type's ideas immediately
|
||||
this.emitter.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10));
|
||||
}
|
||||
@@ -214,6 +307,8 @@ export class AgentQueueManager {
|
||||
if (typeFailedMatch) {
|
||||
const [, ideationType] = typeFailedMatch;
|
||||
completedTypes.add(ideationType);
|
||||
|
||||
debugError('[Agent Queue] Ideation type failed:', { projectId, ideationType });
|
||||
this.emitter.emit('ideation-type-failed', projectId, ideationType);
|
||||
}
|
||||
|
||||
@@ -254,6 +349,17 @@ export class AgentQueueManager {
|
||||
|
||||
// Handle process exit
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
debugLog('[Agent Queue] Ideation process exited:', { projectId, code, spawnId });
|
||||
|
||||
// Check if this process was intentionally stopped by the user
|
||||
const wasIntentionallyStopped = this.state.wasSpawnKilled(spawnId);
|
||||
if (wasIntentionallyStopped) {
|
||||
debugLog('[Agent Queue] Ideation process was intentionally stopped, ignoring exit');
|
||||
this.state.clearKilledSpawn(spawnId);
|
||||
this.state.deleteProcess(projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the stored project path before deleting from map
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const storedProjectPath = processInfo?.projectPath;
|
||||
@@ -261,8 +367,10 @@ export class AgentQueueManager {
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
debugLog('[Agent Queue] Checking for rate limit (non-zero exit)');
|
||||
const rateLimitDetection = detectRateLimit(allOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
debugLog('[Agent Queue] Rate limit detected for ideation');
|
||||
const rateLimitInfo = createSDKRateLimitInfo('ideation', rateLimitDetection, {
|
||||
projectId
|
||||
});
|
||||
@@ -271,6 +379,7 @@ export class AgentQueueManager {
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
debugLog('[Agent Queue] Ideation generation completed successfully');
|
||||
this.emitter.emit('ideation-progress', projectId, {
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
@@ -286,18 +395,25 @@ export class AgentQueueManager {
|
||||
'ideation',
|
||||
'ideation.json'
|
||||
);
|
||||
debugLog('[Agent Queue] Loading ideation session from:', ideationFilePath);
|
||||
if (existsSync(ideationFilePath)) {
|
||||
const content = readFileSync(ideationFilePath, 'utf-8');
|
||||
const session = JSON.parse(content);
|
||||
debugLog('[Agent Queue] Loaded ideation session:', {
|
||||
totalIdeas: session.ideas?.length || 0
|
||||
});
|
||||
this.emitter.emit('ideation-complete', projectId, session);
|
||||
} else {
|
||||
debugError('[Ideation] ideation.json not found at:', ideationFilePath);
|
||||
console.warn('[Ideation] ideation.json not found at:', ideationFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
debugError('[Ideation] Failed to load ideation session:', err);
|
||||
console.error('[Ideation] Failed to load ideation session:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugError('[Agent Queue] Ideation generation failed:', { projectId, code });
|
||||
this.emitter.emit('ideation-error', projectId, `Ideation generation failed with exit code ${code}`);
|
||||
}
|
||||
});
|
||||
@@ -318,11 +434,17 @@ export class AgentQueueManager {
|
||||
projectPath: string,
|
||||
args: string[]
|
||||
): void {
|
||||
debugLog('[Agent Queue] Spawning roadmap process:', { projectId, projectPath });
|
||||
|
||||
// Kill existing process for this project if any
|
||||
this.processManager.killProcess(projectId);
|
||||
const wasKilled = this.processManager.killProcess(projectId);
|
||||
if (wasKilled) {
|
||||
debugLog('[Agent Queue] Killed existing roadmap process for project:', projectId);
|
||||
}
|
||||
|
||||
// Generate unique spawn ID for this process instance
|
||||
const spawnId = this.state.generateSpawnId();
|
||||
debugLog('[Agent Queue] Generated roadmap spawn ID:', spawnId);
|
||||
|
||||
// Run from auto-claude source directory so imports work correctly
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
@@ -331,22 +453,44 @@ export class AgentQueueManager {
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
// Get Python path from process manager (uses venv if configured)
|
||||
const pythonPath = this.processManager.getPythonPath();
|
||||
|
||||
const childProcess = spawn(pythonPath, args, {
|
||||
// Build final environment with proper precedence:
|
||||
// 1. process.env (system)
|
||||
// 2. combinedEnv (auto-claude/.env for CLI usage)
|
||||
// 3. profileEnv (Electron app OAuth token - highest priority)
|
||||
// 4. Our specific overrides
|
||||
const finalEnv = {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONPATH: autoBuildSource || '', // Allow imports from auto-claude directory
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
};
|
||||
|
||||
// Debug: Show OAuth token source
|
||||
const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN']
|
||||
? 'Electron app profile'
|
||||
: (combinedEnv['CLAUDE_CODE_OAUTH_TOKEN'] ? 'auto-claude/.env' : 'not found');
|
||||
const oauthToken = (finalEnv as Record<string, string | undefined>)['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
const hasToken = !!oauthToken;
|
||||
debugLog('[Agent Queue] OAuth token status:', {
|
||||
source: tokenSource,
|
||||
hasToken,
|
||||
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
|
||||
});
|
||||
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
}
|
||||
env: finalEnv
|
||||
});
|
||||
|
||||
this.state.addProcess(projectId, {
|
||||
@@ -354,7 +498,8 @@ export class AgentQueueManager {
|
||||
process: childProcess,
|
||||
startedAt: new Date(),
|
||||
projectPath, // Store project path for loading roadmap on completion
|
||||
spawnId
|
||||
spawnId,
|
||||
queueProcessType: 'roadmap'
|
||||
});
|
||||
|
||||
// Track progress through output
|
||||
@@ -412,6 +557,17 @@ export class AgentQueueManager {
|
||||
|
||||
// Handle process exit
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
debugLog('[Agent Queue] Roadmap process exited:', { projectId, code, spawnId });
|
||||
|
||||
// Check if this process was intentionally stopped by the user
|
||||
const wasIntentionallyStopped = this.state.wasSpawnKilled(spawnId);
|
||||
if (wasIntentionallyStopped) {
|
||||
debugLog('[Agent Queue] Roadmap process was intentionally stopped, ignoring exit');
|
||||
this.state.clearKilledSpawn(spawnId);
|
||||
this.state.deleteProcess(projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the stored project path before deleting from map
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const storedProjectPath = processInfo?.projectPath;
|
||||
@@ -419,8 +575,10 @@ export class AgentQueueManager {
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
debugLog('[Agent Queue] Checking for rate limit (non-zero exit)');
|
||||
const rateLimitDetection = detectRateLimit(allRoadmapOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
debugLog('[Agent Queue] Rate limit detected for roadmap');
|
||||
const rateLimitInfo = createSDKRateLimitInfo('roadmap', rateLimitDetection, {
|
||||
projectId
|
||||
});
|
||||
@@ -429,6 +587,7 @@ export class AgentQueueManager {
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
debugLog('[Agent Queue] Roadmap generation completed successfully');
|
||||
this.emitter.emit('roadmap-progress', projectId, {
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
@@ -444,18 +603,26 @@ export class AgentQueueManager {
|
||||
'roadmap',
|
||||
'roadmap.json'
|
||||
);
|
||||
debugLog('[Agent Queue] Loading roadmap from:', roadmapFilePath);
|
||||
if (existsSync(roadmapFilePath)) {
|
||||
const content = readFileSync(roadmapFilePath, 'utf-8');
|
||||
const roadmap = JSON.parse(content);
|
||||
debugLog('[Agent Queue] Loaded roadmap:', {
|
||||
featuresCount: roadmap.features?.length || 0,
|
||||
phasesCount: roadmap.phases?.length || 0
|
||||
});
|
||||
this.emitter.emit('roadmap-complete', projectId, roadmap);
|
||||
} else {
|
||||
debugError('[Roadmap] roadmap.json not found at:', roadmapFilePath);
|
||||
console.warn('[Roadmap] roadmap.json not found at:', roadmapFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
debugError('[Roadmap] Failed to load roadmap:', err);
|
||||
console.error('[Roadmap] Failed to load roadmap:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugError('[Agent Queue] Roadmap generation failed:', { projectId, code });
|
||||
this.emitter.emit('roadmap-error', projectId, `Roadmap generation failed with exit code ${code}`);
|
||||
}
|
||||
});
|
||||
@@ -472,12 +639,19 @@ export class AgentQueueManager {
|
||||
* Stop ideation generation for a project
|
||||
*/
|
||||
stopIdeation(projectId: string): boolean {
|
||||
const wasRunning = this.state.hasProcess(projectId);
|
||||
if (wasRunning) {
|
||||
debugLog('[Agent Queue] Stop ideation requested:', { projectId });
|
||||
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const isIdeation = processInfo?.queueProcessType === 'ideation';
|
||||
debugLog('[Agent Queue] Process running?', { projectId, isIdeation, processType: processInfo?.queueProcessType });
|
||||
|
||||
if (isIdeation) {
|
||||
debugLog('[Agent Queue] Killing ideation process:', projectId);
|
||||
this.processManager.killProcess(projectId);
|
||||
this.emitter.emit('ideation-stopped', projectId);
|
||||
return true;
|
||||
}
|
||||
debugLog('[Agent Queue] No running ideation process found for:', projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -485,6 +659,35 @@ export class AgentQueueManager {
|
||||
* Check if ideation is running for a project
|
||||
*/
|
||||
isIdeationRunning(projectId: string): boolean {
|
||||
return this.state.hasProcess(projectId);
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
return processInfo?.queueProcessType === 'ideation';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop roadmap generation for a project
|
||||
*/
|
||||
stopRoadmap(projectId: string): boolean {
|
||||
debugLog('[Agent Queue] Stop roadmap requested:', { projectId });
|
||||
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const isRoadmap = processInfo?.queueProcessType === 'roadmap';
|
||||
debugLog('[Agent Queue] Roadmap process running?', { projectId, isRoadmap, processType: processInfo?.queueProcessType });
|
||||
|
||||
if (isRoadmap) {
|
||||
debugLog('[Agent Queue] Killing roadmap process:', projectId);
|
||||
this.processManager.killProcess(projectId);
|
||||
this.emitter.emit('roadmap-stopped', projectId);
|
||||
return true;
|
||||
}
|
||||
debugLog('[Agent Queue] No running roadmap process found for:', projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if roadmap is running for a project
|
||||
*/
|
||||
isRoadmapRunning(projectId: string): boolean {
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
return processInfo?.queueProcessType === 'roadmap';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ export type {
|
||||
ExecutionProgressData,
|
||||
ProcessType,
|
||||
AgentManagerEvents,
|
||||
IdeationConfig,
|
||||
TaskExecutionOptions,
|
||||
SpecCreationMetadata,
|
||||
IdeationProgressData,
|
||||
RoadmapProgressData
|
||||
} from './types';
|
||||
|
||||
// Re-export IdeationConfig from shared types for consistency
|
||||
export type { IdeationConfig } from '../../shared/types';
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Agent-specific types for process and state management
|
||||
*/
|
||||
|
||||
export type QueueProcessType = 'ideation' | 'roadmap';
|
||||
|
||||
export interface AgentProcess {
|
||||
taskId: string;
|
||||
process: ChildProcess;
|
||||
startedAt: Date;
|
||||
projectPath?: string; // For ideation processes to load session on completion
|
||||
spawnId: number; // Unique ID to identify this specific spawn
|
||||
queueProcessType?: QueueProcessType; // Type of queue process (ideation or roadmap)
|
||||
}
|
||||
|
||||
export interface ExecutionProgressData {
|
||||
@@ -29,12 +33,11 @@ export interface AgentManagerEvents {
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
|
||||
}
|
||||
|
||||
export interface IdeationConfig {
|
||||
enabledTypes: string[];
|
||||
includeRoadmapContext: boolean;
|
||||
includeKanbanContext: boolean;
|
||||
maxIdeasPerType: number;
|
||||
append?: boolean;
|
||||
// IdeationConfig now imported from shared types to maintain consistency
|
||||
|
||||
export interface RoadmapConfig {
|
||||
model?: string; // Model shorthand (opus, sonnet, haiku)
|
||||
thinkingLevel?: string; // Thinking level (none, low, medium, high, ultrathink)
|
||||
}
|
||||
|
||||
export interface TaskExecutionOptions {
|
||||
@@ -45,6 +48,23 @@ export interface TaskExecutionOptions {
|
||||
|
||||
export interface SpecCreationMetadata {
|
||||
requireReviewBeforeCoding?: boolean;
|
||||
// Auto profile - phase-based model and thinking configuration
|
||||
isAutoProfile?: boolean;
|
||||
phaseModels?: {
|
||||
spec: 'haiku' | 'sonnet' | 'opus';
|
||||
planning: 'haiku' | 'sonnet' | 'opus';
|
||||
coding: 'haiku' | 'sonnet' | 'opus';
|
||||
qa: 'haiku' | 'sonnet' | 'opus';
|
||||
};
|
||||
phaseThinking?: {
|
||||
spec: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
planning: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
coding: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
qa: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
};
|
||||
// Non-auto profile - single model and thinking level
|
||||
model?: 'haiku' | 'sonnet' | 'opus';
|
||||
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
}
|
||||
|
||||
export interface IdeationProgressData {
|
||||
|
||||
@@ -23,8 +23,8 @@ import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
import type { AppUpdateInfo } from '../shared/types';
|
||||
|
||||
// Debug mode - set via environment variable
|
||||
const DEBUG_UPDATER = process.env.DEBUG_UPDATER === 'true' || process.env.DEBUG === 'true';
|
||||
// Debug mode - DEBUG_UPDATER=true or development mode
|
||||
const DEBUG_UPDATER = process.env.DEBUG_UPDATER === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
// Configure electron-updater
|
||||
autoUpdater.autoDownload = true; // Automatically download updates when available
|
||||
|
||||
@@ -27,7 +27,7 @@ export type {
|
||||
} from './updater/types';
|
||||
|
||||
// Export version management
|
||||
export { getBundledVersion } from './updater/version-manager';
|
||||
export { getBundledVersion, getEffectiveVersion } from './updater/version-manager';
|
||||
|
||||
// Export path resolution
|
||||
export {
|
||||
|
||||
@@ -27,13 +27,15 @@ import {
|
||||
getCommits,
|
||||
getBranchDiffCommits
|
||||
} from './git-integration';
|
||||
import { findPythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Main changelog service - orchestrates all changelog operations
|
||||
* Delegates to specialized modules for specific concerns
|
||||
*/
|
||||
export class ChangelogService extends EventEmitter {
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private claudePath: string = 'claude';
|
||||
private autoBuildSourcePath: string = '';
|
||||
private cachedEnv: Record<string, string> | null = null;
|
||||
@@ -89,7 +91,7 @@ export class ChangelogService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
* Checks DEBUG from auto-claude/.env and AUTO_CLAUDE_DEBUG from process.env
|
||||
* Checks DEBUG from auto-claude/.env and DEBUG from process.env
|
||||
*/
|
||||
private isDebugEnabled(): boolean {
|
||||
// Cache the result after first check
|
||||
@@ -101,8 +103,8 @@ export class ChangelogService extends EventEmitter {
|
||||
if (
|
||||
process.env.DEBUG === 'true' ||
|
||||
process.env.DEBUG === '1' ||
|
||||
process.env.AUTO_CLAUDE_DEBUG === 'true' ||
|
||||
process.env.AUTO_CLAUDE_DEBUG === '1'
|
||||
process.env.DEBUG === 'true' ||
|
||||
process.env.DEBUG === '1'
|
||||
) {
|
||||
this.debugEnabled = true;
|
||||
return true;
|
||||
@@ -115,7 +117,7 @@ export class ChangelogService extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when DEBUG=true in auto-claude/.env or AUTO_CLAUDE_DEBUG is set
|
||||
* Debug logging - only logs when DEBUG=true in auto-claude/.env or DEBUG is set
|
||||
*/
|
||||
private debug(...args: unknown[]): void {
|
||||
if (this.isDebugEnabled()) {
|
||||
|
||||
@@ -49,7 +49,11 @@ const FORMAT_TEMPLATES = {
|
||||
|
||||
## What's Changed
|
||||
|
||||
- type: description by @contributor in commit-hash`
|
||||
- type: description by @contributor in commit-hash
|
||||
|
||||
## Thanks to all contributors
|
||||
|
||||
@contributor1, @contributor2`
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -274,7 +278,15 @@ PART 2 - "What's Changed" (raw commit list):
|
||||
- Example: "- feat: add dark mode support by @contributor in def5678"
|
||||
- Include the commit type prefix (feat:, fix:, docs:, etc.)
|
||||
- Show the author name with @ prefix
|
||||
- Show the short commit hash at the end`;
|
||||
- Show the short commit hash at the end
|
||||
|
||||
PART 3 - "Thanks to all contributors" (deduplicated list):
|
||||
- Add this section after "What's Changed"
|
||||
- Extract all unique contributor names from the commits
|
||||
- List them in a comma-separated format with @ prefix
|
||||
- Example: "## Thanks to all contributors\\n\\n@contributor1, @contributor2, @contributor3"
|
||||
- Only include unique names (no duplicates)
|
||||
- This acknowledges everyone who contributed to this release`;
|
||||
}
|
||||
|
||||
return `${audienceInstruction}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildChangelogPrompt, buildGitPrompt, createGenerationScript } from './
|
||||
import { extractChangelog } from './parser';
|
||||
import { getCommits, getBranchDiffCommits } from './git-integration';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Core changelog generation logic
|
||||
@@ -139,7 +140,9 @@ export class ChangelogGenerator extends EventEmitter {
|
||||
// Build environment with explicit critical variables
|
||||
const spawnEnv = this.buildSpawnEnvironment();
|
||||
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
|
||||
cwd: this.autoBuildSourcePath,
|
||||
env: spawnEnv
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { GitCommit } from '../../shared/types';
|
||||
import { getProfileEnv } from '../rate-limit-detector';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
|
||||
interface VersionSuggestion {
|
||||
version: string;
|
||||
@@ -52,7 +53,9 @@ export class VersionSuggester {
|
||||
const spawnEnv = this.buildSpawnEnvironment();
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
|
||||
cwd: this.autoBuildSourcePath,
|
||||
env: spawnEnv
|
||||
});
|
||||
|
||||
@@ -451,6 +451,34 @@ export class ClaudeProfileManager {
|
||||
return isProfileAuthenticatedImpl(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile has valid authentication for starting tasks.
|
||||
* A profile is considered authenticated if:
|
||||
* 1) It has a valid OAuth token (not expired), OR
|
||||
* 2) It has an authenticated configDir (credential files exist)
|
||||
*
|
||||
* @param profileId - Optional profile ID to check. If not provided, checks active profile.
|
||||
* @returns true if the profile can authenticate, false otherwise
|
||||
*/
|
||||
hasValidAuth(profileId?: string): boolean {
|
||||
const profile = profileId ? this.getProfile(profileId) : this.getActiveProfile();
|
||||
if (!profile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check 1: Profile has a valid OAuth token
|
||||
if (hasValidToken(profile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2 & 3: Profile has authenticated configDir (works for both default and non-default)
|
||||
if (this.isProfileAuthenticated(profile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for invoking Claude with a specific profile
|
||||
*/
|
||||
|
||||
@@ -99,7 +99,9 @@ export class UsageMonitor extends EventEmitter {
|
||||
}
|
||||
|
||||
// Fetch current usage (hybrid approach)
|
||||
const usage = await this.fetchUsage(activeProfile.id, activeProfile.oauthToken);
|
||||
// Get decrypted token from ProfileManager (activeProfile.oauthToken is encrypted)
|
||||
const decryptedToken = profileManager.getProfileToken(activeProfile.id);
|
||||
const usage = await this.fetchUsage(activeProfile.id, decryptedToken ?? undefined);
|
||||
if (!usage) {
|
||||
console.warn('[UsageMonitor] Failed to fetch usage');
|
||||
return;
|
||||
|
||||
@@ -139,6 +139,14 @@ app.whenReady().then(() => {
|
||||
usageMonitor.start();
|
||||
console.warn('[main] Usage monitor initialized and started');
|
||||
|
||||
// Log debug mode status
|
||||
const isDebugMode = process.env.DEBUG === 'true';
|
||||
if (isDebugMode) {
|
||||
console.warn('[main] ========================================');
|
||||
console.warn('[main] DEBUG MODE ENABLED (DEBUG=true)');
|
||||
console.warn('[main] ========================================');
|
||||
}
|
||||
|
||||
// Initialize app auto-updater (only in production, or when DEBUG_UPDATER is set)
|
||||
const forceUpdater = process.env.DEBUG_UPDATER === 'true';
|
||||
if (app.isPackaged || forceUpdater) {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { EventEmitter } from 'events';
|
||||
import type {
|
||||
InsightsSession,
|
||||
InsightsSessionSummary,
|
||||
InsightsChatMessage
|
||||
InsightsChatMessage,
|
||||
InsightsModelConfig
|
||||
} from '../shared/types';
|
||||
import { InsightsConfig } from './insights/config';
|
||||
import { InsightsPaths } from './insights/paths';
|
||||
@@ -111,7 +112,12 @@ export class InsightsService extends EventEmitter {
|
||||
/**
|
||||
* Send a message and get AI response
|
||||
*/
|
||||
async sendMessage(projectId: string, projectPath: string, message: string): Promise<void> {
|
||||
async sendMessage(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
message: string,
|
||||
modelConfig?: InsightsModelConfig
|
||||
): Promise<void> {
|
||||
// Cancel any existing session
|
||||
this.executor.cancelSession(projectId);
|
||||
|
||||
@@ -150,13 +156,17 @@ export class InsightsService extends EventEmitter {
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
// Use provided modelConfig or fall back to session's config
|
||||
const configToUse = modelConfig || session.modelConfig;
|
||||
|
||||
try {
|
||||
// Execute insights query
|
||||
const result = await this.executor.execute(
|
||||
projectId,
|
||||
projectPath,
|
||||
message,
|
||||
conversationHistory
|
||||
conversationHistory,
|
||||
configToUse
|
||||
);
|
||||
|
||||
// Add assistant message to session
|
||||
@@ -177,6 +187,13 @@ export class InsightsService extends EventEmitter {
|
||||
console.error('[InsightsService] Error executing insights:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model configuration for a session
|
||||
*/
|
||||
updateSessionModelConfig(projectPath: string, sessionId: string, modelConfig: InsightsModelConfig): boolean {
|
||||
return this.sessionManager.updateSessionModelConfig(projectPath, sessionId, modelConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -2,13 +2,15 @@ import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { app } from 'electron';
|
||||
import { getProfileEnv } from '../rate-limit-detector';
|
||||
import { findPythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Configuration manager for insights service
|
||||
* Handles path detection and environment variable loading
|
||||
*/
|
||||
export class InsightsConfig {
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, writeFileSync, unlinkSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { EventEmitter } from 'events';
|
||||
import type {
|
||||
InsightsChatMessage,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
InsightsToolUsage
|
||||
InsightsToolUsage,
|
||||
InsightsModelConfig
|
||||
} from '../../shared/types';
|
||||
import { MODEL_ID_MAP } from '../../shared/constants';
|
||||
import { InsightsConfig } from './config';
|
||||
import { detectRateLimit, createSDKRateLimitInfo } from '../rate-limit-detector';
|
||||
|
||||
@@ -59,7 +62,8 @@ export class InsightsExecutor extends EventEmitter {
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
message: string,
|
||||
conversationHistory: Array<{ role: string; content: string }>
|
||||
conversationHistory: Array<{ role: string; content: string }>,
|
||||
modelConfig?: InsightsModelConfig
|
||||
): Promise<ProcessorResult> {
|
||||
// Cancel any existing session
|
||||
this.cancelSession(projectId);
|
||||
@@ -69,7 +73,7 @@ export class InsightsExecutor extends EventEmitter {
|
||||
throw new Error('Auto Claude source not found');
|
||||
}
|
||||
|
||||
const runnerPath = path.join(autoBuildSource, 'insights_runner.py');
|
||||
const runnerPath = path.join(autoBuildSource, 'runners', 'insights_runner.py');
|
||||
if (!existsSync(runnerPath)) {
|
||||
throw new Error('insights_runner.py not found in auto-claude directory');
|
||||
}
|
||||
@@ -83,13 +87,38 @@ export class InsightsExecutor extends EventEmitter {
|
||||
// Get process environment
|
||||
const processEnv = this.config.getProcessEnv();
|
||||
|
||||
// Spawn Python process
|
||||
const proc = spawn(this.config.getPythonPath(), [
|
||||
// Write conversation history to temp file to avoid Windows command-line length limit
|
||||
const historyFile = path.join(
|
||||
os.tmpdir(),
|
||||
`insights-history-${projectId}-${Date.now()}.json`
|
||||
);
|
||||
|
||||
let historyFileCreated = false;
|
||||
try {
|
||||
writeFileSync(historyFile, JSON.stringify(conversationHistory), 'utf-8');
|
||||
historyFileCreated = true;
|
||||
} catch (err) {
|
||||
console.error('[Insights] Failed to write history file:', err);
|
||||
throw new Error('Failed to write conversation history to temp file');
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
runnerPath,
|
||||
'--project-dir', projectPath,
|
||||
'--message', message,
|
||||
'--history', JSON.stringify(conversationHistory)
|
||||
], {
|
||||
'--history-file', historyFile
|
||||
];
|
||||
|
||||
// Add model config if provided
|
||||
if (modelConfig) {
|
||||
const modelId = MODEL_ID_MAP[modelConfig.model] || MODEL_ID_MAP['sonnet'];
|
||||
args.push('--model', modelId);
|
||||
args.push('--thinking-level', modelConfig.thinkingLevel);
|
||||
}
|
||||
|
||||
// Spawn Python process
|
||||
const proc = spawn(this.config.getPythonPath(), args, {
|
||||
cwd: autoBuildSource,
|
||||
env: processEnv
|
||||
});
|
||||
@@ -138,6 +167,15 @@ export class InsightsExecutor extends EventEmitter {
|
||||
proc.on('close', (code) => {
|
||||
this.activeSessions.delete(projectId);
|
||||
|
||||
// Cleanup temp file
|
||||
if (historyFileCreated && existsSync(historyFile)) {
|
||||
try {
|
||||
unlinkSync(historyFile);
|
||||
} catch (cleanupErr) {
|
||||
console.error('[Insights] Failed to cleanup history file:', cleanupErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
this.handleRateLimit(projectId, allInsightsOutput);
|
||||
@@ -171,6 +209,16 @@ export class InsightsExecutor extends EventEmitter {
|
||||
|
||||
proc.on('error', (err) => {
|
||||
this.activeSessions.delete(projectId);
|
||||
|
||||
// Cleanup temp file
|
||||
if (historyFileCreated && existsSync(historyFile)) {
|
||||
try {
|
||||
unlinkSync(historyFile);
|
||||
} catch (cleanupErr) {
|
||||
console.error('[Insights] Failed to cleanup history file:', cleanupErr);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('error', projectId, err.message);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InsightsSession, InsightsSessionSummary } from '../../shared/types';
|
||||
import type { InsightsSession, InsightsSessionSummary, InsightsModelConfig } from '../../shared/types';
|
||||
import { SessionStorage } from './session-storage';
|
||||
import { InsightsPaths } from './paths';
|
||||
|
||||
@@ -119,6 +119,30 @@ export class SessionManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model configuration for a session
|
||||
*/
|
||||
updateSessionModelConfig(projectPath: string, sessionId: string, modelConfig: InsightsModelConfig): boolean {
|
||||
const session = this.storage.loadSessionById(projectPath, sessionId);
|
||||
if (!session) return false;
|
||||
|
||||
session.modelConfig = modelConfig;
|
||||
session.updatedAt = new Date();
|
||||
this.storage.saveSession(projectPath, session);
|
||||
|
||||
// Update cache if this session is cached
|
||||
for (const [projectId, cachedSession] of this.sessions) {
|
||||
if (cachedSession.id === sessionId) {
|
||||
cachedSession.modelConfig = modelConfig;
|
||||
cachedSession.updatedAt = new Date();
|
||||
this.sessions.set(projectId, cachedSession);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session to disk and update cache
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Integration module for external roadmap/feedback services
|
||||
*
|
||||
* Currently provides architecture for future integrations with:
|
||||
* - Canny.io (feedback management)
|
||||
* - GitHub Issues
|
||||
*
|
||||
* To add a new integration:
|
||||
* 1. Implement the IntegrationAdapter interface
|
||||
* 2. Add status mapping constants
|
||||
* 3. Register the adapter in this module
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
|
||||
// Future: Export concrete adapter implementations
|
||||
// export { CannyAdapter } from './canny-adapter';
|
||||
// export { GitHubIssuesAdapter } from './github-issues-adapter';
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Integration provider types for external roadmap services (Canny, GitHub Issues, etc.)
|
||||
*
|
||||
* This architecture allows bidirectional sync with external feedback/roadmap systems:
|
||||
* - Import: Fetch feature requests from external services
|
||||
* - Export: Push status updates back when features progress
|
||||
*/
|
||||
|
||||
import type { RoadmapFeatureStatus } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Represents an item from an external feedback/roadmap system
|
||||
*/
|
||||
export interface FeedbackItem {
|
||||
externalId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
votes: number;
|
||||
status: string; // Provider-specific status
|
||||
url: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection status for a provider
|
||||
*/
|
||||
export interface ProviderConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
lastSync?: Date;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a provider
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
enabled: boolean;
|
||||
apiKey?: string;
|
||||
boardId?: string;
|
||||
autoSync?: boolean;
|
||||
syncIntervalMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract interface for integration adapters
|
||||
*
|
||||
* Implement this interface to add support for new external services.
|
||||
* Each adapter handles mapping between internal and external status systems.
|
||||
*/
|
||||
export interface IntegrationAdapter {
|
||||
/** Unique identifier for this provider */
|
||||
readonly providerId: string;
|
||||
|
||||
/** Display name for the provider */
|
||||
readonly providerName: string;
|
||||
|
||||
/**
|
||||
* Test the connection to the external service
|
||||
*/
|
||||
testConnection(): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
/**
|
||||
* Fetch all items from the external service
|
||||
*/
|
||||
fetchItems(): Promise<FeedbackItem[]>;
|
||||
|
||||
/**
|
||||
* Update the status of an item in the external service
|
||||
*/
|
||||
updateStatus(externalId: string, status: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Map internal roadmap status to provider-specific status
|
||||
*/
|
||||
mapStatusToProvider(internalStatus: RoadmapFeatureStatus): string;
|
||||
|
||||
/**
|
||||
* Map provider-specific status to internal roadmap status
|
||||
*/
|
||||
mapStatusFromProvider(externalStatus: string): RoadmapFeatureStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canny-specific status mapping
|
||||
* Reference: https://developers.canny.io/api-reference
|
||||
*/
|
||||
export const CANNY_STATUS_MAP = {
|
||||
toProvider: {
|
||||
under_review: 'under review',
|
||||
planned: 'planned',
|
||||
in_progress: 'in progress',
|
||||
done: 'complete'
|
||||
} as Record<RoadmapFeatureStatus, string>,
|
||||
|
||||
fromProvider: {
|
||||
'open': 'under_review',
|
||||
'under review': 'under_review',
|
||||
'planned': 'planned',
|
||||
'in progress': 'in_progress',
|
||||
'complete': 'done',
|
||||
'closed': 'done'
|
||||
} as Record<string, RoadmapFeatureStatus>
|
||||
};
|
||||
|
||||
/**
|
||||
* GitHub Issues status mapping
|
||||
*/
|
||||
export const GITHUB_ISSUES_STATUS_MAP = {
|
||||
toProvider: {
|
||||
under_review: 'open',
|
||||
planned: 'open',
|
||||
in_progress: 'open',
|
||||
done: 'closed'
|
||||
} as Record<RoadmapFeatureStatus, string>,
|
||||
|
||||
fromProvider: {
|
||||
'open': 'under_review',
|
||||
'closed': 'done'
|
||||
} as Record<string, RoadmapFeatureStatus>
|
||||
};
|
||||
@@ -5,7 +5,8 @@ import type { IPCResult } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import type { AutoBuildSourceUpdateProgress, SourceEnvConfig, SourceEnvCheckResult } from '../../shared/types';
|
||||
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveSourcePath } from '../auto-claude-updater';
|
||||
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveVersion, getEffectiveSourcePath } from '../auto-claude-updater';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
|
||||
/**
|
||||
@@ -21,10 +22,16 @@ export function registerAutobuildSourceHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK,
|
||||
async (): Promise<IPCResult<{ updateAvailable: boolean; currentVersion: string; latestVersion?: string; releaseNotes?: string; releaseUrl?: string; error?: string }>> => {
|
||||
console.log('[autobuild-source] Check for updates called');
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK called');
|
||||
try {
|
||||
const result = await checkSourceUpdates();
|
||||
console.log('[autobuild-source] Check result:', JSON.stringify(result, null, 2));
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK result:', result);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('[autobuild-source] Check error:', error);
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates'
|
||||
@@ -36,25 +43,33 @@ export function registerAutobuildSourceHandlers(
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD,
|
||||
() => {
|
||||
debugLog('[IPC] Autobuild source download requested');
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
if (!mainWindow) {
|
||||
debugLog('[IPC] No main window available, aborting update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start download in background
|
||||
downloadAndApplyUpdate((progress) => {
|
||||
debugLog('[IPC] Update progress:', progress.stage, progress.message);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
progress
|
||||
);
|
||||
}).then((result) => {
|
||||
if (result.success) {
|
||||
debugLog('[IPC] Update completed successfully, version:', result.version);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'complete',
|
||||
message: `Updated to version ${result.version}`
|
||||
message: `Updated to version ${result.version}`,
|
||||
newVersion: result.version // Include new version for UI refresh
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
} else {
|
||||
debugLog('[IPC] Update failed:', result.error);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
@@ -64,6 +79,7 @@ export function registerAutobuildSourceHandlers(
|
||||
);
|
||||
}
|
||||
}).catch((error) => {
|
||||
debugLog('[IPC] Update error:', error instanceof Error ? error.message : error);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
@@ -88,7 +104,9 @@ export function registerAutobuildSourceHandlers(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION,
|
||||
async (): Promise<IPCResult<string>> => {
|
||||
try {
|
||||
const version = getBundledVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const version = getEffectiveVersion();
|
||||
debugLog('[IPC] Returning effective version:', version);
|
||||
return { success: true, data: version };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -62,9 +62,48 @@ export function registerEnvHandlers(
|
||||
if (config.githubAutoSync !== undefined) {
|
||||
existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false';
|
||||
}
|
||||
// Git/Worktree Settings
|
||||
if (config.defaultBranch !== undefined) {
|
||||
existingVars['DEFAULT_BRANCH'] = config.defaultBranch;
|
||||
}
|
||||
if (config.graphitiEnabled !== undefined) {
|
||||
existingVars['GRAPHITI_ENABLED'] = config.graphitiEnabled ? 'true' : 'false';
|
||||
}
|
||||
// Graphiti Provider Configuration
|
||||
if (config.graphitiProviderConfig) {
|
||||
const pc = config.graphitiProviderConfig;
|
||||
if (pc.llmProvider) existingVars['GRAPHITI_LLM_PROVIDER'] = pc.llmProvider;
|
||||
if (pc.embeddingProvider) existingVars['GRAPHITI_EMBEDDER_PROVIDER'] = pc.embeddingProvider;
|
||||
// OpenAI
|
||||
if (pc.openaiApiKey) existingVars['OPENAI_API_KEY'] = pc.openaiApiKey;
|
||||
if (pc.openaiModel) existingVars['OPENAI_MODEL'] = pc.openaiModel;
|
||||
if (pc.openaiEmbeddingModel) existingVars['OPENAI_EMBEDDING_MODEL'] = pc.openaiEmbeddingModel;
|
||||
// Anthropic
|
||||
if (pc.anthropicApiKey) existingVars['ANTHROPIC_API_KEY'] = pc.anthropicApiKey;
|
||||
if (pc.anthropicModel) existingVars['GRAPHITI_ANTHROPIC_MODEL'] = pc.anthropicModel;
|
||||
// Azure OpenAI
|
||||
if (pc.azureOpenaiApiKey) existingVars['AZURE_OPENAI_API_KEY'] = pc.azureOpenaiApiKey;
|
||||
if (pc.azureOpenaiBaseUrl) existingVars['AZURE_OPENAI_BASE_URL'] = pc.azureOpenaiBaseUrl;
|
||||
if (pc.azureOpenaiLlmDeployment) existingVars['AZURE_OPENAI_LLM_DEPLOYMENT'] = pc.azureOpenaiLlmDeployment;
|
||||
if (pc.azureOpenaiEmbeddingDeployment) existingVars['AZURE_OPENAI_EMBEDDING_DEPLOYMENT'] = pc.azureOpenaiEmbeddingDeployment;
|
||||
// Voyage
|
||||
if (pc.voyageApiKey) existingVars['VOYAGE_API_KEY'] = pc.voyageApiKey;
|
||||
if (pc.voyageEmbeddingModel) existingVars['VOYAGE_EMBEDDING_MODEL'] = pc.voyageEmbeddingModel;
|
||||
// Google
|
||||
if (pc.googleApiKey) existingVars['GOOGLE_API_KEY'] = pc.googleApiKey;
|
||||
if (pc.googleLlmModel) existingVars['GOOGLE_LLM_MODEL'] = pc.googleLlmModel;
|
||||
if (pc.googleEmbeddingModel) existingVars['GOOGLE_EMBEDDING_MODEL'] = pc.googleEmbeddingModel;
|
||||
// Ollama
|
||||
if (pc.ollamaBaseUrl) existingVars['OLLAMA_BASE_URL'] = pc.ollamaBaseUrl;
|
||||
if (pc.ollamaLlmModel) existingVars['OLLAMA_LLM_MODEL'] = pc.ollamaLlmModel;
|
||||
if (pc.ollamaEmbeddingModel) existingVars['OLLAMA_EMBEDDING_MODEL'] = pc.ollamaEmbeddingModel;
|
||||
if (pc.ollamaEmbeddingDim) existingVars['OLLAMA_EMBEDDING_DIM'] = String(pc.ollamaEmbeddingDim);
|
||||
// FalkorDB
|
||||
if (pc.falkorDbHost) existingVars['GRAPHITI_FALKORDB_HOST'] = pc.falkorDbHost;
|
||||
if (pc.falkorDbPort) existingVars['GRAPHITI_FALKORDB_PORT'] = String(pc.falkorDbPort);
|
||||
if (pc.falkorDbPassword) existingVars['GRAPHITI_FALKORDB_PASSWORD'] = pc.falkorDbPassword;
|
||||
}
|
||||
// Legacy fields (still supported)
|
||||
if (config.openaiApiKey !== undefined) {
|
||||
existingVars['OPENAI_API_KEY'] = config.openaiApiKey;
|
||||
}
|
||||
@@ -109,6 +148,13 @@ ${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}`
|
||||
${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'}
|
||||
${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'}
|
||||
|
||||
# =============================================================================
|
||||
# GIT/WORKTREE SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Default base branch for worktree creation
|
||||
# If not set, Auto Claude will auto-detect main/master, or fall back to current branch
|
||||
${existingVars['DEFAULT_BRANCH'] ? `DEFAULT_BRANCH=${existingVars['DEFAULT_BRANCH']}` : '# DEFAULT_BRANCH=main'}
|
||||
|
||||
# =============================================================================
|
||||
# UI SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
@@ -116,9 +162,45 @@ ${existingVars['ENABLE_FANCY_UI'] !== undefined ? `ENABLE_FANCY_UI=${existingVar
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI MEMORY INTEGRATION (OPTIONAL)
|
||||
# Multi-provider support: OpenAI, Anthropic, Google AI, Azure OpenAI, Ollama, Voyage
|
||||
# =============================================================================
|
||||
${existingVars['GRAPHITI_ENABLED'] ? `GRAPHITI_ENABLED=${existingVars['GRAPHITI_ENABLED']}` : '# GRAPHITI_ENABLED=false'}
|
||||
|
||||
# Provider Selection
|
||||
${existingVars['GRAPHITI_LLM_PROVIDER'] ? `GRAPHITI_LLM_PROVIDER=${existingVars['GRAPHITI_LLM_PROVIDER']}` : '# GRAPHITI_LLM_PROVIDER=openai'}
|
||||
${existingVars['GRAPHITI_EMBEDDER_PROVIDER'] ? `GRAPHITI_EMBEDDER_PROVIDER=${existingVars['GRAPHITI_EMBEDDER_PROVIDER']}` : '# GRAPHITI_EMBEDDER_PROVIDER=openai'}
|
||||
|
||||
# OpenAI Settings
|
||||
${existingVars['OPENAI_API_KEY'] ? `OPENAI_API_KEY=${existingVars['OPENAI_API_KEY']}` : '# OPENAI_API_KEY='}
|
||||
${existingVars['OPENAI_MODEL'] ? `OPENAI_MODEL=${existingVars['OPENAI_MODEL']}` : '# OPENAI_MODEL=gpt-4o-mini'}
|
||||
${existingVars['OPENAI_EMBEDDING_MODEL'] ? `OPENAI_EMBEDDING_MODEL=${existingVars['OPENAI_EMBEDDING_MODEL']}` : '# OPENAI_EMBEDDING_MODEL=text-embedding-3-small'}
|
||||
|
||||
# Anthropic Settings (LLM only - use with Voyage or OpenAI for embeddings)
|
||||
${existingVars['ANTHROPIC_API_KEY'] ? `ANTHROPIC_API_KEY=${existingVars['ANTHROPIC_API_KEY']}` : '# ANTHROPIC_API_KEY='}
|
||||
${existingVars['GRAPHITI_ANTHROPIC_MODEL'] ? `GRAPHITI_ANTHROPIC_MODEL=${existingVars['GRAPHITI_ANTHROPIC_MODEL']}` : '# GRAPHITI_ANTHROPIC_MODEL=claude-sonnet-4-5-latest'}
|
||||
|
||||
# Azure OpenAI Settings
|
||||
${existingVars['AZURE_OPENAI_API_KEY'] ? `AZURE_OPENAI_API_KEY=${existingVars['AZURE_OPENAI_API_KEY']}` : '# AZURE_OPENAI_API_KEY='}
|
||||
${existingVars['AZURE_OPENAI_BASE_URL'] ? `AZURE_OPENAI_BASE_URL=${existingVars['AZURE_OPENAI_BASE_URL']}` : '# AZURE_OPENAI_BASE_URL='}
|
||||
${existingVars['AZURE_OPENAI_LLM_DEPLOYMENT'] ? `AZURE_OPENAI_LLM_DEPLOYMENT=${existingVars['AZURE_OPENAI_LLM_DEPLOYMENT']}` : '# AZURE_OPENAI_LLM_DEPLOYMENT='}
|
||||
${existingVars['AZURE_OPENAI_EMBEDDING_DEPLOYMENT'] ? `AZURE_OPENAI_EMBEDDING_DEPLOYMENT=${existingVars['AZURE_OPENAI_EMBEDDING_DEPLOYMENT']}` : '# AZURE_OPENAI_EMBEDDING_DEPLOYMENT='}
|
||||
|
||||
# Voyage AI Settings (Embeddings only - great with Anthropic)
|
||||
${existingVars['VOYAGE_API_KEY'] ? `VOYAGE_API_KEY=${existingVars['VOYAGE_API_KEY']}` : '# VOYAGE_API_KEY='}
|
||||
${existingVars['VOYAGE_EMBEDDING_MODEL'] ? `VOYAGE_EMBEDDING_MODEL=${existingVars['VOYAGE_EMBEDDING_MODEL']}` : '# VOYAGE_EMBEDDING_MODEL=voyage-3'}
|
||||
|
||||
# Google AI Settings (LLM and Embeddings - Gemini)
|
||||
${existingVars['GOOGLE_API_KEY'] ? `GOOGLE_API_KEY=${existingVars['GOOGLE_API_KEY']}` : '# GOOGLE_API_KEY='}
|
||||
${existingVars['GOOGLE_LLM_MODEL'] ? `GOOGLE_LLM_MODEL=${existingVars['GOOGLE_LLM_MODEL']}` : '# GOOGLE_LLM_MODEL=gemini-2.0-flash'}
|
||||
${existingVars['GOOGLE_EMBEDDING_MODEL'] ? `GOOGLE_EMBEDDING_MODEL=${existingVars['GOOGLE_EMBEDDING_MODEL']}` : '# GOOGLE_EMBEDDING_MODEL=text-embedding-004'}
|
||||
|
||||
# Ollama Settings (Local - free)
|
||||
${existingVars['OLLAMA_BASE_URL'] ? `OLLAMA_BASE_URL=${existingVars['OLLAMA_BASE_URL']}` : '# OLLAMA_BASE_URL=http://localhost:11434'}
|
||||
${existingVars['OLLAMA_LLM_MODEL'] ? `OLLAMA_LLM_MODEL=${existingVars['OLLAMA_LLM_MODEL']}` : '# OLLAMA_LLM_MODEL='}
|
||||
${existingVars['OLLAMA_EMBEDDING_MODEL'] ? `OLLAMA_EMBEDDING_MODEL=${existingVars['OLLAMA_EMBEDDING_MODEL']}` : '# OLLAMA_EMBEDDING_MODEL='}
|
||||
${existingVars['OLLAMA_EMBEDDING_DIM'] ? `OLLAMA_EMBEDDING_DIM=${existingVars['OLLAMA_EMBEDDING_DIM']}` : '# OLLAMA_EMBEDDING_DIM=768'}
|
||||
|
||||
# FalkorDB Connection
|
||||
${existingVars['GRAPHITI_FALKORDB_HOST'] ? `GRAPHITI_FALKORDB_HOST=${existingVars['GRAPHITI_FALKORDB_HOST']}` : '# GRAPHITI_FALKORDB_HOST=localhost'}
|
||||
${existingVars['GRAPHITI_FALKORDB_PORT'] ? `GRAPHITI_FALKORDB_PORT=${existingVars['GRAPHITI_FALKORDB_PORT']}` : '# GRAPHITI_FALKORDB_PORT=6380'}
|
||||
${existingVars['GRAPHITI_FALKORDB_PASSWORD'] ? `GRAPHITI_FALKORDB_PASSWORD=${existingVars['GRAPHITI_FALKORDB_PASSWORD']}` : '# GRAPHITI_FALKORDB_PASSWORD='}
|
||||
@@ -216,6 +298,11 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
config.githubAutoSync = true;
|
||||
}
|
||||
|
||||
// Git/Worktree config
|
||||
if (vars['DEFAULT_BRANCH']) {
|
||||
config.defaultBranch = vars['DEFAULT_BRANCH'];
|
||||
}
|
||||
|
||||
if (vars['GRAPHITI_ENABLED']?.toLowerCase() === 'true') {
|
||||
config.graphitiEnabled = true;
|
||||
}
|
||||
@@ -246,6 +333,45 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
config.enableFancyUi = false;
|
||||
}
|
||||
|
||||
// Populate graphitiProviderConfig from .env file
|
||||
const llmProvider = vars['GRAPHITI_LLM_PROVIDER'];
|
||||
const embeddingProvider = vars['GRAPHITI_EMBEDDER_PROVIDER'];
|
||||
if (llmProvider || embeddingProvider || vars['ANTHROPIC_API_KEY'] || vars['AZURE_OPENAI_API_KEY'] ||
|
||||
vars['VOYAGE_API_KEY'] || vars['GOOGLE_API_KEY'] || vars['OLLAMA_BASE_URL']) {
|
||||
config.graphitiProviderConfig = {
|
||||
llmProvider: (llmProvider as 'openai' | 'anthropic' | 'azure_openai' | 'ollama' | 'google' | 'groq') || 'openai',
|
||||
embeddingProvider: (embeddingProvider as 'openai' | 'voyage' | 'azure_openai' | 'ollama' | 'google' | 'huggingface') || 'openai',
|
||||
// OpenAI
|
||||
openaiApiKey: vars['OPENAI_API_KEY'],
|
||||
openaiModel: vars['OPENAI_MODEL'],
|
||||
openaiEmbeddingModel: vars['OPENAI_EMBEDDING_MODEL'],
|
||||
// Anthropic
|
||||
anthropicApiKey: vars['ANTHROPIC_API_KEY'],
|
||||
anthropicModel: vars['GRAPHITI_ANTHROPIC_MODEL'],
|
||||
// Azure OpenAI
|
||||
azureOpenaiApiKey: vars['AZURE_OPENAI_API_KEY'],
|
||||
azureOpenaiBaseUrl: vars['AZURE_OPENAI_BASE_URL'],
|
||||
azureOpenaiLlmDeployment: vars['AZURE_OPENAI_LLM_DEPLOYMENT'],
|
||||
azureOpenaiEmbeddingDeployment: vars['AZURE_OPENAI_EMBEDDING_DEPLOYMENT'],
|
||||
// Voyage
|
||||
voyageApiKey: vars['VOYAGE_API_KEY'],
|
||||
voyageEmbeddingModel: vars['VOYAGE_EMBEDDING_MODEL'],
|
||||
// Google
|
||||
googleApiKey: vars['GOOGLE_API_KEY'],
|
||||
googleLlmModel: vars['GOOGLE_LLM_MODEL'],
|
||||
googleEmbeddingModel: vars['GOOGLE_EMBEDDING_MODEL'],
|
||||
// Ollama
|
||||
ollamaBaseUrl: vars['OLLAMA_BASE_URL'],
|
||||
ollamaLlmModel: vars['OLLAMA_LLM_MODEL'],
|
||||
ollamaEmbeddingModel: vars['OLLAMA_EMBEDDING_MODEL'],
|
||||
ollamaEmbeddingDim: vars['OLLAMA_EMBEDDING_DIM'] ? parseInt(vars['OLLAMA_EMBEDDING_DIM'], 10) : undefined,
|
||||
// FalkorDB
|
||||
falkorDbHost: vars['GRAPHITI_FALKORDB_HOST'],
|
||||
falkorDbPort: vars['GRAPHITI_FALKORDB_PORT'] ? parseInt(vars['GRAPHITI_FALKORDB_PORT'], 10) : undefined,
|
||||
falkorDbPassword: vars['GRAPHITI_FALKORDB_PASSWORD'],
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, data: config };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { IPCResult, FileNode } from '../../shared/types';
|
||||
const IGNORED_DIRS = new Set([
|
||||
'node_modules', '.git', '__pycache__', 'dist', 'build',
|
||||
'.next', '.nuxt', 'coverage', '.cache', '.venv', 'venv',
|
||||
'.idea', '.vscode', 'out', '.turbo', '.auto-claude',
|
||||
'.worktrees', 'vendor', 'target', '.gradle', '.maven'
|
||||
'out', '.turbo', '.worktrees',
|
||||
'vendor', 'target', '.gradle', '.maven'
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -29,8 +29,11 @@ export function registerFileHandlers(): void {
|
||||
// Filter and map entries
|
||||
const nodes: FileNode[] = [];
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files (except .env which is often useful)
|
||||
if (entry.name.startsWith('.') && entry.name !== '.env') continue;
|
||||
// Skip hidden files (not directories) except useful ones like .env, .gitignore
|
||||
if (!entry.isDirectory() && entry.name.startsWith('.') &&
|
||||
!['.env', '.gitignore', '.env.example', '.env.local'].includes(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
// Skip ignored directories
|
||||
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Unit tests for GitHub OAuth handlers
|
||||
* Tests device code parsing, shell.openExternal handling, and error recovery
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Mock child_process before importing
|
||||
const mockSpawn = vi.fn();
|
||||
const mockExecSync = vi.fn();
|
||||
const mockExecFileSync = vi.fn();
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
execSync: (...args: unknown[]) => mockExecSync(...args),
|
||||
execFileSync: (...args: unknown[]) => mockExecFileSync(...args)
|
||||
}));
|
||||
|
||||
// Mock shell.openExternal
|
||||
const mockOpenExternal = vi.fn();
|
||||
|
||||
vi.mock('electron', () => {
|
||||
const mockIpcMain = new (class extends EventEmitter {
|
||||
private handlers: Map<string, Function> = new Map();
|
||||
|
||||
handle(channel: string, handler: Function): void {
|
||||
this.handlers.set(channel, handler);
|
||||
}
|
||||
|
||||
removeHandler(channel: string): void {
|
||||
this.handlers.delete(channel);
|
||||
}
|
||||
|
||||
async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise<unknown> {
|
||||
const handler = this.handlers.get(channel);
|
||||
if (handler) {
|
||||
return handler(event, ...args);
|
||||
}
|
||||
throw new Error(`No handler for channel: ${channel}`);
|
||||
}
|
||||
|
||||
getHandler(channel: string): Function | undefined {
|
||||
return this.handlers.get(channel);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
ipcMain: mockIpcMain,
|
||||
shell: {
|
||||
openExternal: (...args: unknown[]) => mockOpenExternal(...args)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @electron-toolkit/utils
|
||||
vi.mock('@electron-toolkit/utils', () => ({
|
||||
is: {
|
||||
dev: true,
|
||||
windows: process.platform === 'win32',
|
||||
macos: process.platform === 'darwin',
|
||||
linux: process.platform === 'linux'
|
||||
}
|
||||
}));
|
||||
|
||||
// Create mock process for spawn
|
||||
function createMockProcess(): EventEmitter & {
|
||||
stdout: EventEmitter | null;
|
||||
stderr: EventEmitter | null;
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> } | null;
|
||||
} {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter | null;
|
||||
stderr: EventEmitter | null;
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> } | null;
|
||||
};
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = { write: vi.fn(), end: vi.fn() };
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe('GitHub OAuth Handlers', () => {
|
||||
let ipcMain: EventEmitter & {
|
||||
handlers: Map<string, Function>;
|
||||
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
getHandler: (channel: string) => Function | undefined;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// Get mocked ipcMain
|
||||
const electron = await import('electron');
|
||||
ipcMain = electron.ipcMain as unknown as typeof ipcMain;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Device Code Parsing', () => {
|
||||
it('should parse device code from standard gh CLI output format', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
// Start the handler
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Simulate gh CLI output with device code
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: ABCD-1234\n');
|
||||
mockProcess.stderr?.emit('data', '- Press Enter to open github.com in your browser...\n');
|
||||
|
||||
// Complete the process
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
const data = (result as { data: { deviceCode: string } }).data;
|
||||
expect(data.deviceCode).toBe('ABCD-1234');
|
||||
});
|
||||
|
||||
it('should parse device code from alternate output format (lowercase "code")', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Alternate format: "code: XXXX-XXXX" without "one-time"
|
||||
mockProcess.stderr?.emit('data', 'Enter the code: EFGH-5678\n');
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { deviceCode: string } }).data;
|
||||
expect(data.deviceCode).toBe('EFGH-5678');
|
||||
});
|
||||
|
||||
it('should parse device code from stdout (not just stderr)', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Device code in stdout instead of stderr
|
||||
mockProcess.stdout?.emit('data', '! First copy your one-time code: IJKL-9012\n');
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { deviceCode: string } }).data;
|
||||
expect(data.deviceCode).toBe('IJKL-9012');
|
||||
});
|
||||
|
||||
it('should handle output without device code gracefully', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Output without device code
|
||||
mockProcess.stderr?.emit('data', 'Some other message\n');
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { deviceCode?: string } }).data;
|
||||
expect(data.deviceCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should extract URL from output containing https://github.com/login/device', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: MNOP-3456\n');
|
||||
mockProcess.stderr?.emit('data', 'Then visit https://github.com/login/device to authenticate\n');
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { authUrl: string } }).data;
|
||||
expect(data.authUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell.openExternal Handling', () => {
|
||||
it('should call shell.openExternal with extracted URL when device code found', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: QRST-7890\n');
|
||||
|
||||
// Wait for next tick to allow async browser opening
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
await resultPromise;
|
||||
|
||||
expect(mockOpenExternal).toHaveBeenCalledWith('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should set browserOpened to true when shell.openExternal succeeds', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: UVWX-1234\n');
|
||||
|
||||
// Wait for async browser opening
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { browserOpened: boolean } }).data;
|
||||
expect(data.browserOpened).toBe(true);
|
||||
});
|
||||
|
||||
it('should set browserOpened to false when shell.openExternal fails', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockRejectedValue(new Error('Failed to open browser'));
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: YZAB-5678\n');
|
||||
|
||||
// Wait for async browser opening to fail
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { browserOpened: boolean } }).data;
|
||||
expect(data.browserOpened).toBe(false);
|
||||
});
|
||||
|
||||
it('should provide fallbackUrl when browser fails to open', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockRejectedValue(new Error('Failed to open browser'));
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: CDEF-9012\n');
|
||||
|
||||
// Wait for async browser opening to fail
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { fallbackUrl?: string } }).data;
|
||||
expect(data.fallbackUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should not provide fallbackUrl when browser opens successfully', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: GHIJ-3456\n');
|
||||
|
||||
// Wait for async browser opening
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { fallbackUrl?: string } }).data;
|
||||
expect(data.fallbackUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle gh CLI process error', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Emit error event
|
||||
mockProcess.emit('error', new Error('spawn gh ENOENT'));
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
expect(result).toHaveProperty('error', 'spawn gh ENOENT');
|
||||
const data = (result as { data: { fallbackUrl: string } }).data;
|
||||
expect(data.fallbackUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should handle non-zero exit code', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', 'error: some authentication error\n');
|
||||
mockProcess.emit('close', 1);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
const data = (result as { data: { fallbackUrl: string } }).data;
|
||||
expect(data.fallbackUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should include device code in error result if it was extracted before failure', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Device code output followed by failure
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: KLMN-7890\n');
|
||||
|
||||
// Wait for async browser opening
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.stderr?.emit('data', 'error: authentication failed\n');
|
||||
mockProcess.emit('close', 1);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
const data = (result as { data: { deviceCode: string; fallbackUrl: string } }).data;
|
||||
expect(data.deviceCode).toBe('KLMN-7890');
|
||||
expect(data.fallbackUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should provide user-friendly error message on process spawn failure', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.emit('error', new Error('spawn gh ENOENT'));
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
const data = (result as { data: { message: string } }).data;
|
||||
expect(data.message).toContain('Failed to start GitHub CLI');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gh CLI Check Handler', () => {
|
||||
it('should return installed: true when gh CLI is found', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes('which gh') || cmd.includes('where gh')) {
|
||||
return '/usr/local/bin/gh\n';
|
||||
}
|
||||
if (cmd === 'gh --version') {
|
||||
return 'gh version 2.65.0 (2024-01-15)\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const { registerCheckGhCli } = await import('../oauth-handlers');
|
||||
registerCheckGhCli();
|
||||
|
||||
const result = await ipcMain.invokeHandler('github:checkCli', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { installed: boolean; version: string } }).data;
|
||||
expect(data.installed).toBe(true);
|
||||
expect(data.version).toContain('gh version');
|
||||
});
|
||||
|
||||
it('should return installed: false when gh CLI is not found', async () => {
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
|
||||
const { registerCheckGhCli } = await import('../oauth-handlers');
|
||||
registerCheckGhCli();
|
||||
|
||||
const result = await ipcMain.invokeHandler('github:checkCli', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { installed: boolean } }).data;
|
||||
expect(data.installed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gh Auth Check Handler', () => {
|
||||
it('should return authenticated: true with username when logged in', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'gh auth status') {
|
||||
return 'Logged in to github.com as testuser\n';
|
||||
}
|
||||
if (cmd === 'gh api user --jq .login') {
|
||||
return 'testuser\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const { registerCheckGhAuth } = await import('../oauth-handlers');
|
||||
registerCheckGhAuth();
|
||||
|
||||
const result = await ipcMain.invokeHandler('github:checkAuth', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { authenticated: boolean; username: string } }).data;
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.username).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should return authenticated: false when not logged in', async () => {
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('You are not logged into any GitHub hosts');
|
||||
});
|
||||
|
||||
const { registerCheckGhAuth } = await import('../oauth-handlers');
|
||||
registerCheckGhAuth();
|
||||
|
||||
const result = await ipcMain.invokeHandler('github:checkAuth', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { authenticated: boolean } }).data;
|
||||
expect(data.authenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spawn Arguments', () => {
|
||||
it('should spawn gh with correct auth login arguments', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
['auth', 'login', '--web', '--scopes', 'repo'],
|
||||
expect.objectContaining({
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository Validation', () => {
|
||||
it('should reject invalid repository format', async () => {
|
||||
const { registerGetGitHubBranches } = await import('../oauth-handlers');
|
||||
registerGetGitHubBranches();
|
||||
|
||||
// Test with injection attempt
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'github:getBranches',
|
||||
{},
|
||||
'owner/repo; rm -rf /',
|
||||
'token'
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
expect(result).toHaveProperty('error', 'Invalid repository format. Expected: owner/repo');
|
||||
});
|
||||
|
||||
it('should accept valid repository format', async () => {
|
||||
mockExecFileSync.mockReturnValue('main\nfeature-branch\n');
|
||||
|
||||
const { registerGetGitHubBranches } = await import('../oauth-handlers');
|
||||
registerGetGitHubBranches();
|
||||
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'github:getBranches',
|
||||
{},
|
||||
'valid-owner/valid-repo',
|
||||
'token'
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: string[] }).data;
|
||||
expect(data).toContain('main');
|
||||
expect(data).toContain('feature-branch');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,11 +47,12 @@ export function registerImportIssues(agentManager: AgentManager): void {
|
||||
};
|
||||
|
||||
// Build description with metadata
|
||||
const labels = issue.labels.map(l => l.name).join(', ');
|
||||
const labelNames = issue.labels.map(l => l.name);
|
||||
const labelsString = labelNames.join(', ');
|
||||
const description = `# ${issue.title}
|
||||
|
||||
**GitHub Issue:** [#${issue.number}](${issue.html_url})
|
||||
${labels ? `**Labels:** ${labels}` : ''}
|
||||
${labelsString ? `**Labels:** ${labelsString}` : ''}
|
||||
|
||||
## Description
|
||||
|
||||
@@ -64,7 +65,8 @@ ${issue.body || 'No description provided.'}
|
||||
issue.number,
|
||||
issue.title,
|
||||
description,
|
||||
issue.html_url
|
||||
issue.html_url,
|
||||
labelNames
|
||||
);
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
|
||||
@@ -66,7 +66,7 @@ export function registerInvestigateIssue(
|
||||
): void {
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE,
|
||||
async (_, projectId: string, issueNumber: number) => {
|
||||
async (_, projectId: string, issueNumber: number, selectedCommentIds?: number[]) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
@@ -104,11 +104,16 @@ export function registerInvestigateIssue(
|
||||
};
|
||||
|
||||
// Fetch issue comments for more context
|
||||
const comments = await githubFetch(
|
||||
const allComments = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}/comments`
|
||||
) as GitHubAPIComment[];
|
||||
|
||||
// Filter comments based on selection (if provided)
|
||||
const comments = selectedCommentIds && selectedCommentIds.length > 0
|
||||
? allComments.filter(c => selectedCommentIds.includes(c.id))
|
||||
: allComments;
|
||||
|
||||
// Build context for the AI investigation
|
||||
const labels = issue.labels.map(l => l.name);
|
||||
const issueContext = buildIssueContext(
|
||||
@@ -141,17 +146,13 @@ export function registerInvestigateIssue(
|
||||
issue.number,
|
||||
issue.title,
|
||||
taskDescription,
|
||||
issue.html_url
|
||||
issue.html_url,
|
||||
labels
|
||||
);
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
agentManager.startSpecCreation(
|
||||
specData.specId,
|
||||
project.path,
|
||||
specData.taskDescription,
|
||||
specData.specDir,
|
||||
specData.metadata
|
||||
);
|
||||
// NOTE: We intentionally do NOT call agentManager.startSpecCreation() here
|
||||
// This allows the task to stay in "backlog" status until the user manually starts it
|
||||
// Previously, calling startSpecCreation would auto-start the task immediately
|
||||
|
||||
// Phase 3: Creating task
|
||||
sendProgress(mainWindow, projectId, {
|
||||
|
||||
@@ -6,8 +6,8 @@ import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult, GitHubIssue } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getGitHubConfig, githubFetch } from './utils';
|
||||
import type { GitHubAPIIssue } from './types';
|
||||
import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils';
|
||||
import type { GitHubAPIIssue, GitHubAPIComment } from './types';
|
||||
|
||||
/**
|
||||
* Transform GitHub API issue to application format
|
||||
@@ -57,16 +57,32 @@ export function registerGetIssues(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
if (!normalizedRepo) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
|
||||
};
|
||||
}
|
||||
|
||||
const issues = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues?state=${state}&per_page=100&sort=updated`
|
||||
) as GitHubAPIIssue[];
|
||||
`/repos/${normalizedRepo}/issues?state=${state}&per_page=100&sort=updated`
|
||||
);
|
||||
|
||||
// Ensure issues is an array
|
||||
if (!Array.isArray(issues)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unexpected response format from GitHub API'
|
||||
};
|
||||
}
|
||||
|
||||
// Filter out pull requests
|
||||
const issuesOnly = issues.filter(issue => !issue.pull_request);
|
||||
const issuesOnly = issues.filter((issue: GitHubAPIIssue) => !issue.pull_request);
|
||||
|
||||
const result: GitHubIssue[] = issuesOnly.map(issue =>
|
||||
transformIssue(issue, config.repo)
|
||||
const result: GitHubIssue[] = issuesOnly.map((issue: GitHubAPIIssue) =>
|
||||
transformIssue(issue, normalizedRepo)
|
||||
);
|
||||
|
||||
return { success: true, data: result };
|
||||
@@ -98,12 +114,20 @@ export function registerGetIssue(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
if (!normalizedRepo) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
|
||||
};
|
||||
}
|
||||
|
||||
const issue = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}`
|
||||
`/repos/${normalizedRepo}/issues/${issueNumber}`
|
||||
) as GitHubAPIIssue;
|
||||
|
||||
const result = transformIssue(issue, config.repo);
|
||||
const result = transformIssue(issue, normalizedRepo);
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
@@ -116,10 +140,53 @@ export function registerGetIssue(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments for a specific issue
|
||||
*/
|
||||
export function registerGetIssueComments(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_GET_ISSUE_COMMENTS,
|
||||
async (_, projectId: string, issueNumber: number): Promise<IPCResult<GitHubAPIComment[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
return { success: false, error: 'No GitHub token or repository configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
if (!normalizedRepo) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
|
||||
};
|
||||
}
|
||||
|
||||
const comments = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${normalizedRepo}/issues/${issueNumber}/comments`
|
||||
) as GitHubAPIComment[];
|
||||
|
||||
return { success: true, data: comments };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch issue comments'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all issue-related handlers
|
||||
*/
|
||||
export function registerIssueHandlers(): void {
|
||||
registerGetIssues();
|
||||
registerGetIssue();
|
||||
registerGetIssueComments();
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Provides a simpler OAuth flow than manual PAT creation
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { ipcMain, shell } from 'electron';
|
||||
import { execSync, execFileSync, spawn } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
|
||||
@@ -21,6 +21,83 @@ function debugLog(message: string, data?: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Regex pattern to validate GitHub repository format (owner/repo)
|
||||
// Allows alphanumeric characters, hyphens, underscores, and periods
|
||||
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* Validate that a repository string matches the expected owner/repo format
|
||||
* Prevents command injection by rejecting strings with shell metacharacters
|
||||
*/
|
||||
function isValidGitHubRepo(repo: string): boolean {
|
||||
return GITHUB_REPO_PATTERN.test(repo);
|
||||
}
|
||||
|
||||
// Regex patterns for parsing device code from gh CLI output
|
||||
// Expected format: "! First copy your one-time code: XXXX-XXXX"
|
||||
// Pattern updated to handle different gh CLI versions - supports:
|
||||
// - "one-time code", "code", or "verification code" prefixes
|
||||
// - Hyphen or space separator in the code (XXXX-XXXX or XXXX XXXX)
|
||||
// Note: Separator is REQUIRED to avoid matching 8-char strings without separator
|
||||
const DEVICE_CODE_PATTERN = /(?:one-time code|verification code|code):\s*([A-Z0-9]{4}[-\s][A-Z0-9]{4})/i;
|
||||
|
||||
// GitHub device flow URL pattern
|
||||
const DEVICE_URL_PATTERN = /https:\/\/github\.com\/login\/device/i;
|
||||
|
||||
// Default GitHub device flow URL
|
||||
const GITHUB_DEVICE_URL = 'https://github.com/login/device';
|
||||
|
||||
/**
|
||||
* Parse device code from gh CLI stdout output
|
||||
* Returns the device code (format: XXXX-XXXX) if found, null otherwise
|
||||
* Normalizes space separator to hyphen (GitHub always expects XXXX-XXXX)
|
||||
*/
|
||||
function parseDeviceCode(output: string): string | null {
|
||||
const match = output.match(DEVICE_CODE_PATTERN);
|
||||
if (match && match[1]) {
|
||||
// Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format)
|
||||
const normalizedCode = match[1].replace(' ', '-');
|
||||
debugLog('Device code extracted successfully (code redacted for security)');
|
||||
return normalizedCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse device URL from gh CLI output
|
||||
* Returns the URL if found, or the default GitHub device URL
|
||||
*/
|
||||
function parseDeviceUrl(output: string): string {
|
||||
const match = output.match(DEVICE_URL_PATTERN);
|
||||
if (match) {
|
||||
debugLog('Found device URL in output:', match[0]);
|
||||
return match[0];
|
||||
}
|
||||
// Default to standard GitHub device flow URL
|
||||
return GITHUB_DEVICE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing device flow output from gh CLI
|
||||
*/
|
||||
interface DeviceFlowInfo {
|
||||
deviceCode: string | null;
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse both device code and URL from combined gh CLI output
|
||||
* Searches through both stdout and stderr as gh may output to either
|
||||
*/
|
||||
function parseDeviceFlowOutput(stdout: string, stderr: string): DeviceFlowInfo {
|
||||
const combinedOutput = `${stdout}\n${stderr}`;
|
||||
|
||||
return {
|
||||
deviceCode: parseDeviceCode(combinedOutput),
|
||||
authUrl: parseDeviceUrl(combinedOutput)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gh CLI is installed
|
||||
*/
|
||||
@@ -102,14 +179,31 @@ export function registerCheckGhAuth(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for GitHub auth start, including device flow information
|
||||
*/
|
||||
interface GitHubAuthStartResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
deviceCode?: string;
|
||||
authUrl?: string;
|
||||
browserOpened?: boolean;
|
||||
/**
|
||||
* Fallback URL provided when browser launch fails.
|
||||
* The frontend should display this URL so users can manually navigate to complete auth.
|
||||
*/
|
||||
fallbackUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start GitHub OAuth flow using gh CLI
|
||||
* This will open the browser for device flow authentication
|
||||
* This will extract the device code from gh CLI output and open the browser
|
||||
* using Electron's shell.openExternal (bypasses macOS child process restrictions)
|
||||
*/
|
||||
export function registerStartGhAuth(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_START_AUTH,
|
||||
async (): Promise<IPCResult<{ success: boolean; message?: string }>> => {
|
||||
async (): Promise<IPCResult<GitHubAuthStartResult>> => {
|
||||
debugLog('startGitHubAuth handler called');
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
@@ -123,17 +217,64 @@ export function registerStartGhAuth(): void {
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
let deviceCodeExtracted = false;
|
||||
let extractedDeviceCode: string | null = null;
|
||||
let extractedAuthUrl: string = GITHUB_DEVICE_URL;
|
||||
let browserOpenedSuccessfully = false;
|
||||
let extractionInProgress = false;
|
||||
|
||||
// Function to attempt device code extraction and browser opening
|
||||
// Uses mutex pattern to prevent race conditions from concurrent data handlers
|
||||
const tryExtractAndOpenBrowser = async () => {
|
||||
if (deviceCodeExtracted || extractionInProgress) return;
|
||||
extractionInProgress = true;
|
||||
|
||||
const deviceFlowInfo = parseDeviceFlowOutput(output, errorOutput);
|
||||
|
||||
if (deviceFlowInfo.deviceCode) {
|
||||
deviceCodeExtracted = true;
|
||||
extractedDeviceCode = deviceFlowInfo.deviceCode;
|
||||
extractedAuthUrl = deviceFlowInfo.authUrl;
|
||||
|
||||
debugLog('Device code extracted successfully (code redacted for security)');
|
||||
debugLog('Auth URL:', extractedAuthUrl);
|
||||
|
||||
// Open browser using Electron's shell.openExternal
|
||||
// This bypasses macOS child process restrictions that block gh CLI's browser launch
|
||||
try {
|
||||
await shell.openExternal(extractedAuthUrl);
|
||||
browserOpenedSuccessfully = true;
|
||||
debugLog('Browser opened successfully via shell.openExternal');
|
||||
} catch (browserError) {
|
||||
debugLog('Failed to open browser:', browserError instanceof Error ? browserError.message : browserError);
|
||||
browserOpenedSuccessfully = false;
|
||||
// Don't fail here - we'll return the device code so user can manually navigate
|
||||
}
|
||||
|
||||
// Extraction complete - mutex flag stays true to prevent re-extraction
|
||||
// The deviceCodeExtracted flag will prevent future attempts
|
||||
extractionInProgress = false;
|
||||
} else {
|
||||
// No device code found yet, allow next data chunk to try again
|
||||
extractionInProgress = false;
|
||||
}
|
||||
};
|
||||
|
||||
ghProcess.stdout?.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
output += chunk;
|
||||
debugLog('gh stdout:', chunk);
|
||||
// Try to extract device code as data comes in
|
||||
// Use void to explicitly ignore promise
|
||||
void tryExtractAndOpenBrowser();
|
||||
});
|
||||
|
||||
ghProcess.stderr?.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
errorOutput += chunk;
|
||||
debugLog('gh stderr:', chunk);
|
||||
// gh often outputs to stderr, so check there too
|
||||
void tryExtractAndOpenBrowser();
|
||||
});
|
||||
|
||||
ghProcess.on('close', (code) => {
|
||||
@@ -142,17 +283,39 @@ export function registerStartGhAuth(): void {
|
||||
debugLog('Full stderr:', errorOutput);
|
||||
|
||||
if (code === 0) {
|
||||
// Success case - include fallbackUrl if browser failed to open
|
||||
// so the user can manually navigate if needed
|
||||
resolve({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Successfully authenticated with GitHub'
|
||||
message: browserOpenedSuccessfully
|
||||
? 'Successfully authenticated with GitHub'
|
||||
: 'Authentication successful. Browser could not be opened automatically.',
|
||||
deviceCode: extractedDeviceCode || undefined,
|
||||
authUrl: extractedAuthUrl,
|
||||
browserOpened: browserOpenedSuccessfully,
|
||||
// Provide fallback URL when browser failed to open
|
||||
fallbackUrl: !browserOpenedSuccessfully ? extractedAuthUrl : undefined
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Even if auth failed, return device code info if we extracted it
|
||||
// This allows user to retry manually with the fallback URL
|
||||
const fallbackUrlForManualAuth = extractedDeviceCode ? extractedAuthUrl : GITHUB_DEVICE_URL;
|
||||
|
||||
resolve({
|
||||
success: false,
|
||||
error: errorOutput || `Authentication failed with exit code ${code}`
|
||||
error: errorOutput || `Authentication failed with exit code ${code}`,
|
||||
data: {
|
||||
success: false,
|
||||
deviceCode: extractedDeviceCode || undefined,
|
||||
authUrl: extractedAuthUrl,
|
||||
browserOpened: browserOpenedSuccessfully,
|
||||
// Always provide fallback URL on failure for manual recovery
|
||||
fallbackUrl: fallbackUrlForManualAuth,
|
||||
message: 'Authentication failed. Please visit the URL manually to complete authentication.'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -161,14 +324,28 @@ export function registerStartGhAuth(): void {
|
||||
debugLog('gh process error:', error.message);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: error.message,
|
||||
data: {
|
||||
success: false,
|
||||
browserOpened: false,
|
||||
// Provide fallback URL so user can attempt manual auth
|
||||
fallbackUrl: GITHUB_DEVICE_URL,
|
||||
message: 'Failed to start GitHub CLI. Please visit the URL manually to authenticate.'
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
debugLog('Exception in startGitHubAuth:', error instanceof Error ? error.message : error);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
data: {
|
||||
success: false,
|
||||
browserOpened: false,
|
||||
// Provide fallback URL for manual authentication recovery
|
||||
fallbackUrl: GITHUB_DEVICE_URL,
|
||||
message: 'An unexpected error occurred. Please visit the URL manually to authenticate.'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -296,6 +473,106 @@ export function registerListUserRepos(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect GitHub repository from git remote origin
|
||||
*/
|
||||
export function registerDetectGitHubRepo(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_DETECT_REPO,
|
||||
async (_event: Electron.IpcMainInvokeEvent, projectPath: string): Promise<IPCResult<string>> => {
|
||||
debugLog('detectGitHubRepo handler called', { projectPath });
|
||||
try {
|
||||
// Get the remote URL
|
||||
debugLog('Running: git remote get-url origin');
|
||||
const remoteUrl = execSync('git remote get-url origin', {
|
||||
encoding: 'utf-8',
|
||||
cwd: projectPath,
|
||||
stdio: 'pipe'
|
||||
}).trim();
|
||||
|
||||
debugLog('Remote URL:', remoteUrl);
|
||||
|
||||
// Parse GitHub repo from URL
|
||||
// Formats:
|
||||
// - https://github.com/owner/repo.git
|
||||
// - git@github.com:owner/repo.git
|
||||
// - https://github.com/owner/repo
|
||||
const match = remoteUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
const repo = match[1];
|
||||
debugLog('Detected repo:', repo);
|
||||
return {
|
||||
success: true,
|
||||
data: repo
|
||||
};
|
||||
}
|
||||
|
||||
debugLog('Could not parse GitHub repo from URL');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Remote URL is not a GitHub repository'
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog('Failed to detect repo:', error instanceof Error ? error.message : error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to detect GitHub repository'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get branches from GitHub repository
|
||||
*/
|
||||
export function registerGetGitHubBranches(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_GET_BRANCHES,
|
||||
async (_event: Electron.IpcMainInvokeEvent, repo: string, _token: string): Promise<IPCResult<string[]>> => {
|
||||
debugLog('getGitHubBranches handler called', { repo });
|
||||
|
||||
// Validate repo format to prevent command injection
|
||||
if (!isValidGitHubRepo(repo)) {
|
||||
debugLog('Invalid repo format rejected:', repo);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Expected: owner/repo'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Use gh CLI to list branches (uses authenticated session)
|
||||
// Use execFileSync with separate arguments to avoid shell injection
|
||||
const apiEndpoint = `repos/${repo}/branches`;
|
||||
debugLog(`Running: gh api ${apiEndpoint} --paginate --jq '.[].name'`);
|
||||
const output = execFileSync(
|
||||
'gh',
|
||||
['api', apiEndpoint, '--paginate', '--jq', '.[].name'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
}
|
||||
);
|
||||
|
||||
const branches = output.trim().split('\n').filter(b => b.length > 0);
|
||||
debugLog('Found branches:', branches.length);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: branches
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog('Failed to get branches:', error instanceof Error ? error.message : error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get branches'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all GitHub OAuth handlers
|
||||
*/
|
||||
@@ -307,5 +584,7 @@ export function registerGithubOAuthHandlers(): void {
|
||||
registerGetGhToken();
|
||||
registerGetGhUser();
|
||||
registerListUserRepos();
|
||||
registerDetectGitHubRepo();
|
||||
registerGetGitHubBranches();
|
||||
debugLog('GitHub OAuth handlers registered');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult, GitHubRepository, GitHubSyncStatus } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getGitHubConfig, githubFetch } from './utils';
|
||||
import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils';
|
||||
import type { GitHubAPIRepository } from './types';
|
||||
|
||||
/**
|
||||
@@ -33,16 +33,28 @@ export function registerCheckConnection(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize repo reference (handles full URLs, git URLs, etc.)
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
if (!normalizedRepo) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
connected: false,
|
||||
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch repo info
|
||||
const repoData = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}`
|
||||
`/repos/${normalizedRepo}`
|
||||
) as { full_name: string; description?: string };
|
||||
|
||||
// Count open issues
|
||||
const issuesData = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues?state=open&per_page=1`
|
||||
`/repos/${normalizedRepo}/issues?state=open&per_page=1`
|
||||
) as unknown[];
|
||||
|
||||
const openCount = Array.isArray(issuesData) ? issuesData.length : 0;
|
||||
@@ -71,7 +83,7 @@ export function registerCheckConnection(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of GitHub repositories
|
||||
* Get list of GitHub repositories (personal + organization)
|
||||
*/
|
||||
export function registerGetRepositories(): void {
|
||||
ipcMain.handle(
|
||||
@@ -88,9 +100,11 @@ export function registerGetRepositories(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch user's personal + organization repos
|
||||
// affiliation parameter includes: owner, collaborator, organization_member
|
||||
const repos = await githubFetch(
|
||||
config.token,
|
||||
'/user/repos?per_page=100&sort=updated'
|
||||
'/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member'
|
||||
) as GitHubAPIRepository[];
|
||||
|
||||
const result: GitHubRepository[] = repos.map(repo => ({
|
||||
|
||||
@@ -51,6 +51,58 @@ function slugifyTitle(title: string): string {
|
||||
.substring(0, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine task category based on GitHub issue labels
|
||||
* Maps to TaskCategory type from shared/types/task.ts
|
||||
*/
|
||||
function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' | 'refactoring' | 'documentation' | 'security' | 'performance' | 'ui_ux' | 'infrastructure' | 'testing' {
|
||||
const lowerLabels = labels.map(l => l.toLowerCase());
|
||||
|
||||
// Check for bug labels
|
||||
if (lowerLabels.some(l => l.includes('bug') || l.includes('defect') || l.includes('error') || l.includes('fix'))) {
|
||||
return 'bug_fix';
|
||||
}
|
||||
|
||||
// Check for security labels
|
||||
if (lowerLabels.some(l => l.includes('security') || l.includes('vulnerability') || l.includes('cve'))) {
|
||||
return 'security';
|
||||
}
|
||||
|
||||
// Check for performance labels
|
||||
if (lowerLabels.some(l => l.includes('performance') || l.includes('optimization') || l.includes('speed'))) {
|
||||
return 'performance';
|
||||
}
|
||||
|
||||
// Check for UI/UX labels
|
||||
if (lowerLabels.some(l => l.includes('ui') || l.includes('ux') || l.includes('design') || l.includes('styling'))) {
|
||||
return 'ui_ux';
|
||||
}
|
||||
|
||||
// Check for infrastructure labels
|
||||
if (lowerLabels.some(l => l.includes('infrastructure') || l.includes('devops') || l.includes('deployment') || l.includes('ci') || l.includes('cd'))) {
|
||||
return 'infrastructure';
|
||||
}
|
||||
|
||||
// Check for testing labels
|
||||
if (lowerLabels.some(l => l.includes('test') || l.includes('testing') || l.includes('qa'))) {
|
||||
return 'testing';
|
||||
}
|
||||
|
||||
// Check for refactoring labels
|
||||
if (lowerLabels.some(l => l.includes('refactor') || l.includes('cleanup') || l.includes('maintenance') || l.includes('chore') || l.includes('tech-debt') || l.includes('technical debt'))) {
|
||||
return 'refactoring';
|
||||
}
|
||||
|
||||
// Check for documentation labels
|
||||
if (lowerLabels.some(l => l.includes('documentation') || l.includes('docs'))) {
|
||||
return 'documentation';
|
||||
}
|
||||
|
||||
// Check for enhancement/feature labels (default)
|
||||
// This catches 'enhancement', 'feature', 'improvement', or any unlabeled issues
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new spec directory and initial files
|
||||
*/
|
||||
@@ -59,7 +111,8 @@ export function createSpecForIssue(
|
||||
issueNumber: number,
|
||||
issueTitle: string,
|
||||
taskDescription: string,
|
||||
githubUrl: string
|
||||
githubUrl: string,
|
||||
labels: string[] = []
|
||||
): SpecCreationData {
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
@@ -104,12 +157,15 @@ export function createSpecForIssue(
|
||||
JSON.stringify(requirements, null, 2)
|
||||
);
|
||||
|
||||
// Determine category from GitHub issue labels
|
||||
const category = determineCategoryFromLabels(labels);
|
||||
|
||||
// task_metadata.json
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'github',
|
||||
githubIssueNumber: issueNumber,
|
||||
githubUrl,
|
||||
category: 'feature'
|
||||
category
|
||||
};
|
||||
writeFileSync(
|
||||
path.join(specDir, 'task_metadata.json'),
|
||||
|
||||
@@ -38,8 +38,11 @@ export interface GitHubAPIRepository {
|
||||
}
|
||||
|
||||
export interface GitHubAPIComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string };
|
||||
user: { login: string; avatar_url?: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReleaseOptions {
|
||||
|
||||
@@ -54,6 +54,32 @@ export function getGitHubConfig(project: Project): GitHubConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a GitHub repository reference to owner/repo format
|
||||
* Handles:
|
||||
* - owner/repo (already normalized)
|
||||
* - https://github.com/owner/repo
|
||||
* - https://github.com/owner/repo.git
|
||||
* - git@github.com:owner/repo.git
|
||||
*/
|
||||
export function normalizeRepoReference(repo: string): string {
|
||||
if (!repo) return '';
|
||||
|
||||
// Remove trailing .git if present
|
||||
let normalized = repo.replace(/\.git$/, '');
|
||||
|
||||
// Handle full GitHub URLs
|
||||
if (normalized.startsWith('https://github.com/')) {
|
||||
normalized = normalized.replace('https://github.com/', '');
|
||||
} else if (normalized.startsWith('http://github.com/')) {
|
||||
normalized = normalized.replace('http://github.com/', '');
|
||||
} else if (normalized.startsWith('git@github.com:')) {
|
||||
normalized = normalized.replace('git@github.com:', '');
|
||||
}
|
||||
|
||||
return normalized.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the GitHub API
|
||||
*/
|
||||
@@ -69,7 +95,7 @@ export async function githubFetch(
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
...options.headers
|
||||
|
||||
@@ -3,10 +3,45 @@
|
||||
*/
|
||||
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent, BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult, IdeationConfig, IdeationGenerationStatus } from '../../../shared/types';
|
||||
import { app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../../shared/constants';
|
||||
import type { IPCResult, IdeationConfig, IdeationGenerationStatus, AppSettings } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import type { AgentManager } from '../../agent';
|
||||
import { debugLog, debugError } from '../../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Read ideation feature settings from the settings file
|
||||
*/
|
||||
function getIdeationFeatureSettings(): { model?: string; thinkingLevel?: string } {
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) };
|
||||
|
||||
// Get ideation-specific settings
|
||||
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
|
||||
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
|
||||
|
||||
return {
|
||||
model: featureModels.ideation,
|
||||
thinkingLevel: featureThinking.ideation
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugError('[Ideation Handler] Failed to read feature settings:', error);
|
||||
}
|
||||
|
||||
// Return defaults if settings file doesn't exist or fails to parse
|
||||
return {
|
||||
model: DEFAULT_FEATURE_MODELS.ideation,
|
||||
thinkingLevel: DEFAULT_FEATURE_THINKING.ideation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ideation generation for a project
|
||||
@@ -18,10 +53,27 @@ export function startIdeationGeneration(
|
||||
agentManager: AgentManager,
|
||||
mainWindow: BrowserWindow | null
|
||||
): void {
|
||||
// Get feature settings and merge with config
|
||||
const featureSettings = getIdeationFeatureSettings();
|
||||
const configWithSettings: IdeationConfig = {
|
||||
...config,
|
||||
model: config.model || featureSettings.model,
|
||||
thinkingLevel: config.thinkingLevel || featureSettings.thinkingLevel
|
||||
};
|
||||
|
||||
debugLog('[Ideation Handler] Start generation request:', {
|
||||
projectId,
|
||||
enabledTypes: configWithSettings.enabledTypes,
|
||||
maxIdeasPerType: configWithSettings.maxIdeasPerType,
|
||||
model: configWithSettings.model,
|
||||
thinkingLevel: configWithSettings.thinkingLevel
|
||||
});
|
||||
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
debugLog('[Ideation Handler] Project not found:', projectId);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.IDEATION_ERROR,
|
||||
projectId,
|
||||
@@ -30,8 +82,15 @@ export function startIdeationGeneration(
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('[Ideation Handler] Starting agent manager generation:', {
|
||||
projectId,
|
||||
projectPath: project.path,
|
||||
model: configWithSettings.model,
|
||||
thinkingLevel: configWithSettings.thinkingLevel
|
||||
});
|
||||
|
||||
// Start ideation generation via agent manager
|
||||
agentManager.startIdeationGeneration(projectId, project.path, config, false);
|
||||
agentManager.startIdeationGeneration(projectId, project.path, configWithSettings, false);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
@@ -55,6 +114,20 @@ export function refreshIdeationSession(
|
||||
agentManager: AgentManager,
|
||||
mainWindow: BrowserWindow | null
|
||||
): void {
|
||||
// Get feature settings and merge with config
|
||||
const featureSettings = getIdeationFeatureSettings();
|
||||
const configWithSettings: IdeationConfig = {
|
||||
...config,
|
||||
model: config.model || featureSettings.model,
|
||||
thinkingLevel: config.thinkingLevel || featureSettings.thinkingLevel
|
||||
};
|
||||
|
||||
debugLog('[Ideation Handler] Refresh session request:', {
|
||||
projectId,
|
||||
model: configWithSettings.model,
|
||||
thinkingLevel: configWithSettings.thinkingLevel
|
||||
});
|
||||
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
@@ -68,7 +141,7 @@ export function refreshIdeationSession(
|
||||
}
|
||||
|
||||
// Start ideation regeneration with refresh flag
|
||||
agentManager.startIdeationGeneration(projectId, project.path, config, true);
|
||||
agentManager.startIdeationGeneration(projectId, project.path, configWithSettings, true);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
@@ -91,9 +164,14 @@ export async function stopIdeationGeneration(
|
||||
agentManager: AgentManager,
|
||||
mainWindow: BrowserWindow | null
|
||||
): Promise<IPCResult> {
|
||||
debugLog('[Ideation Handler] Stop generation request:', { projectId });
|
||||
|
||||
const wasStopped = agentManager.stopIdeation(projectId);
|
||||
|
||||
debugLog('[Ideation Handler] Stop result:', { projectId, wasStopped });
|
||||
|
||||
if (wasStopped && mainWindow) {
|
||||
debugLog('[Ideation Handler] Sending stopped event to renderer');
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readdirSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import type { IPCResult, InsightsSession, InsightsSessionSummary, Task, TaskMetadata } from '../../shared/types';
|
||||
import type { IPCResult, InsightsSession, InsightsSessionSummary, InsightsModelConfig, Task, TaskMetadata } from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import { insightsService } from '../insights-service';
|
||||
|
||||
@@ -32,7 +32,7 @@ export function registerInsightsHandlers(
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.INSIGHTS_SEND_MESSAGE,
|
||||
async (_, projectId: string, message: string) => {
|
||||
async (_, projectId: string, message: string, modelConfig?: InsightsModelConfig) => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
const mainWindow = getMainWindow();
|
||||
@@ -44,7 +44,7 @@ export function registerInsightsHandlers(
|
||||
|
||||
// Note: Python environment initialization should be handled by insightsService
|
||||
// or added here with proper dependency injection if needed
|
||||
insightsService.sendMessage(projectId, project.path, message);
|
||||
insightsService.sendMessage(projectId, project.path, message, modelConfig);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -241,4 +241,57 @@ export function registerInsightsHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Update model configuration for a session
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_UPDATE_MODEL_CONFIG,
|
||||
async (_, projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const success = insightsService.updateSessionModelConfig(project.path, sessionId, modelConfig);
|
||||
if (success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Failed to update model configuration' };
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Insights Event Forwarding (Service -> Renderer)
|
||||
// ============================================
|
||||
|
||||
// Forward streaming chunks to renderer
|
||||
insightsService.on('stream-chunk', (projectId: string, chunk: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, projectId, chunk);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward status updates to renderer
|
||||
insightsService.on('status', (projectId: string, status: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_STATUS, projectId, status);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward errors to renderer
|
||||
insightsService.on('error', (projectId: string, error: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_ERROR, projectId, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward SDK rate limit events to renderer
|
||||
insightsService.on('sdk-rate-limit', (rateLimitInfo: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function registerLinearHandlers(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': apiKey
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
|
||||
@@ -136,8 +136,8 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
// Add process.cwd() as last resort on all platforms
|
||||
possiblePaths.push(path.resolve(process.cwd(), 'auto-claude'));
|
||||
|
||||
// Enable debug logging with AUTO_CLAUDE_DEBUG=1
|
||||
const debug = process.env.AUTO_CLAUDE_DEBUG === '1' || process.env.AUTO_CLAUDE_DEBUG === 'true';
|
||||
// Enable debug logging with DEBUG=1
|
||||
const debug = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
|
||||
|
||||
if (debug) {
|
||||
console.warn('[project-handlers:detectAutoBuildSourcePath] Platform:', process.platform);
|
||||
@@ -164,7 +164,7 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
}
|
||||
|
||||
console.warn('[project-handlers:detectAutoBuildSourcePath] Could not auto-detect Auto Claude source path.');
|
||||
console.warn('[project-handlers:detectAutoBuildSourcePath] Set AUTO_CLAUDE_DEBUG=1 environment variable for detailed path checking.');
|
||||
console.warn('[project-handlers:detectAutoBuildSourcePath] Set DEBUG=1 environment variable for detailed path checking.');
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { ipcMain, app } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants';
|
||||
import type { IPCResult, Roadmap, RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, Task, TaskMetadata, CompetitorAnalysis } from '../../shared/types';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../shared/constants';
|
||||
import type { IPCResult, Roadmap, RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, Task, TaskMetadata, CompetitorAnalysis, AppSettings } from '../../shared/types';
|
||||
import type { RoadmapConfig } from '../agent/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { projectStore } from '../project-store';
|
||||
import { AgentManager } from '../agent';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Read feature settings from the settings file
|
||||
*/
|
||||
function getFeatureSettings(): { model?: string; thinkingLevel?: string } {
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) };
|
||||
|
||||
// Get roadmap-specific settings
|
||||
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
|
||||
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
|
||||
|
||||
return {
|
||||
model: featureModels.roadmap,
|
||||
thinkingLevel: featureThinking.roadmap
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugError('[Roadmap Handler] Failed to read feature settings:', error);
|
||||
}
|
||||
|
||||
// Return defaults if settings file doesn't exist or fails to parse
|
||||
return {
|
||||
model: DEFAULT_FEATURE_MODELS.roadmap,
|
||||
thinkingLevel: DEFAULT_FEATURE_THINKING.roadmap
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@@ -137,7 +170,7 @@ export function registerRoadmapHandlers(
|
||||
impact: feature.impact || 'medium',
|
||||
phaseId: feature.phase_id,
|
||||
dependencies: feature.dependencies || [],
|
||||
status: feature.status || 'idea',
|
||||
status: feature.status || 'under_review',
|
||||
acceptanceCriteria: feature.acceptance_criteria || [],
|
||||
userStories: feature.user_stories || [],
|
||||
linkedSpecId: feature.linked_spec_id,
|
||||
@@ -159,14 +192,39 @@ export function registerRoadmapHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Get roadmap generation status - allows frontend to query if generation is running
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_GET_STATUS,
|
||||
async (_, projectId: string): Promise<IPCResult<{ isRunning: boolean }>> => {
|
||||
const isRunning = agentManager.isRoadmapRunning(projectId);
|
||||
debugLog('[Roadmap Handler] Get status:', { projectId, isRunning });
|
||||
return { success: true, data: { isRunning } };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_GENERATE,
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean) => {
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => {
|
||||
// Get feature settings for roadmap
|
||||
const featureSettings = getFeatureSettings();
|
||||
const config: RoadmapConfig = {
|
||||
model: featureSettings.model,
|
||||
thinkingLevel: featureSettings.thinkingLevel
|
||||
};
|
||||
|
||||
debugLog('[Roadmap Handler] Generate request:', {
|
||||
projectId,
|
||||
enableCompetitorAnalysis,
|
||||
refreshCompetitorAnalysis,
|
||||
config
|
||||
});
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
debugError('[Roadmap Handler] Project not found:', projectId);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_ERROR,
|
||||
projectId,
|
||||
@@ -175,8 +233,21 @@ export function registerRoadmapHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('[Roadmap Handler] Starting agent manager generation:', {
|
||||
projectId,
|
||||
projectPath: project.path,
|
||||
config
|
||||
});
|
||||
|
||||
// Start roadmap generation via agent manager
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, false, enableCompetitorAnalysis ?? false);
|
||||
agentManager.startRoadmapGeneration(
|
||||
projectId,
|
||||
project.path,
|
||||
false, // refresh (not a refresh operation)
|
||||
enableCompetitorAnalysis ?? false,
|
||||
refreshCompetitorAnalysis ?? false,
|
||||
config
|
||||
);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
@@ -193,7 +264,21 @@ export function registerRoadmapHandlers(
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_REFRESH,
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean) => {
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => {
|
||||
// Get feature settings for roadmap
|
||||
const featureSettings = getFeatureSettings();
|
||||
const config: RoadmapConfig = {
|
||||
model: featureSettings.model,
|
||||
thinkingLevel: featureSettings.thinkingLevel
|
||||
};
|
||||
|
||||
debugLog('[Roadmap Handler] Refresh request:', {
|
||||
projectId,
|
||||
enableCompetitorAnalysis,
|
||||
refreshCompetitorAnalysis,
|
||||
config
|
||||
});
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
@@ -208,7 +293,14 @@ export function registerRoadmapHandlers(
|
||||
}
|
||||
|
||||
// Start roadmap regeneration with refresh flag
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, true, enableCompetitorAnalysis ?? false);
|
||||
agentManager.startRoadmapGeneration(
|
||||
projectId,
|
||||
project.path,
|
||||
true, // refresh (this is a refresh operation)
|
||||
enableCompetitorAnalysis ?? false,
|
||||
refreshCompetitorAnalysis ?? false,
|
||||
config
|
||||
);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
@@ -223,6 +315,27 @@ export function registerRoadmapHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_STOP,
|
||||
async (_, projectId: string): Promise<IPCResult> => {
|
||||
debugLog('[Roadmap Handler] Stop generation request:', { projectId });
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
// Stop roadmap generation for this project
|
||||
const wasStopped = agentManager.stopRoadmap(projectId);
|
||||
|
||||
debugLog('[Roadmap Handler] Stop result:', { projectId, wasStopped });
|
||||
|
||||
if (wasStopped && mainWindow) {
|
||||
debugLog('[Roadmap Handler] Sending stopped event to renderer');
|
||||
mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_STOPPED, projectId);
|
||||
}
|
||||
|
||||
return { success: wasStopped };
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Roadmap Save (full state persistence for drag-and-drop)
|
||||
// ============================================
|
||||
@@ -232,7 +345,7 @@ export function registerRoadmapHandlers(
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
features: RoadmapFeature[]
|
||||
roadmapData: Roadmap
|
||||
): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
@@ -251,10 +364,10 @@ export function registerRoadmapHandlers(
|
||||
|
||||
try {
|
||||
const content = readFileSync(roadmapPath, 'utf-8');
|
||||
const roadmap = JSON.parse(content);
|
||||
const existingRoadmap = JSON.parse(content);
|
||||
|
||||
// Transform camelCase features back to snake_case for JSON file
|
||||
roadmap.features = features.map((feature) => ({
|
||||
existingRoadmap.features = roadmapData.features.map((feature) => ({
|
||||
id: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
@@ -272,10 +385,10 @@ export function registerRoadmapHandlers(
|
||||
}));
|
||||
|
||||
// Update metadata timestamp
|
||||
roadmap.metadata = roadmap.metadata || {};
|
||||
roadmap.metadata.updated_at = new Date().toISOString();
|
||||
existingRoadmap.metadata = existingRoadmap.metadata || {};
|
||||
existingRoadmap.metadata.updated_at = new Date().toISOString();
|
||||
|
||||
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
|
||||
writeFileSync(roadmapPath, JSON.stringify(existingRoadmap, null, 2));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '../../shared/types';
|
||||
import { AgentManager } from '../agent';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { getEffectiveVersion } from '../auto-claude-updater';
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
@@ -47,8 +48,8 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
// Add process.cwd() as last resort on all platforms
|
||||
possiblePaths.push(path.resolve(process.cwd(), 'auto-claude'));
|
||||
|
||||
// Enable debug logging with AUTO_CLAUDE_DEBUG=1
|
||||
const debug = process.env.AUTO_CLAUDE_DEBUG === '1' || process.env.AUTO_CLAUDE_DEBUG === 'true';
|
||||
// Enable debug logging with DEBUG=1
|
||||
const debug = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
|
||||
|
||||
if (debug) {
|
||||
console.warn('[detectAutoBuildSourcePath] Platform:', process.platform);
|
||||
@@ -75,7 +76,7 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
}
|
||||
|
||||
console.warn('[detectAutoBuildSourcePath] Could not auto-detect Auto Claude source path. Please configure manually in settings.');
|
||||
console.warn('[detectAutoBuildSourcePath] Set AUTO_CLAUDE_DEBUG=1 environment variable for detailed path checking.');
|
||||
console.warn('[detectAutoBuildSourcePath] Set DEBUG=1 environment variable for detailed path checking.');
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -93,7 +94,8 @@ export function registerSettingsHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_GET,
|
||||
async (): Promise<IPCResult<AppSettings>> => {
|
||||
let settings = { ...DEFAULT_APP_SETTINGS };
|
||||
let settings: AppSettings = { ...DEFAULT_APP_SETTINGS };
|
||||
let needsSave = false;
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
@@ -104,6 +106,18 @@ export function registerSettingsHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: Set agent profile to 'auto' for users who haven't made a selection (one-time)
|
||||
// This ensures new users get the optimized 'auto' profile as the default
|
||||
// while preserving existing user preferences
|
||||
if (!settings._migratedAgentProfileToAuto) {
|
||||
// Only set 'auto' if user hasn't made a selection yet
|
||||
if (!settings.selectedAgentProfile) {
|
||||
settings.selectedAgentProfile = 'auto';
|
||||
}
|
||||
settings._migratedAgentProfileToAuto = true;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// If no manual autoBuildPath is set, try to auto-detect
|
||||
if (!settings.autoBuildPath) {
|
||||
const detectedPath = detectAutoBuildSourcePath();
|
||||
@@ -112,6 +126,16 @@ export function registerSettingsHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Persist migration changes
|
||||
if (needsSave) {
|
||||
try {
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[SETTINGS_GET] Failed to persist migration:', error);
|
||||
// Continue anyway - settings will be migrated in-memory for this session
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: settings as AppSettings };
|
||||
}
|
||||
);
|
||||
@@ -264,7 +288,10 @@ export function registerSettingsHandlers(
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.APP_VERSION, async (): Promise<string> => {
|
||||
return app.getVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const version = getEffectiveVersion();
|
||||
console.log('[settings-handlers] APP_VERSION returning:', version);
|
||||
return version;
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -3,10 +3,12 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/con
|
||||
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { AgentManager } from '../../agent';
|
||||
import { fileWatcher } from '../../file-watcher';
|
||||
import { findTaskAndProject } from './shared';
|
||||
import { checkGitStatus } from '../../project-initializer';
|
||||
import { getClaudeProfileManager } from '../../claude-profile-manager';
|
||||
|
||||
/**
|
||||
* Register task execution handlers (start, stop, review, status management, recovery)
|
||||
@@ -62,6 +64,18 @@ export function registerTaskExecutionHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check authentication - Claude requires valid auth to run tasks
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[TASK_START] No valid authentication for active profile');
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.TASK_ERROR,
|
||||
taskId,
|
||||
'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account, or set an OAuth token.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[TASK_START] Found task:', task.specId, 'status:', task.status, 'subtasks:', task.subtasks.length);
|
||||
|
||||
// Start file watcher for this task
|
||||
@@ -187,6 +201,11 @@ export function registerTaskExecutionHandlers(
|
||||
task.specId
|
||||
);
|
||||
|
||||
// Check if worktree exists - QA needs to run in the worktree where the build happened
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
const worktreeSpecDir = path.join(worktreePath, specsBaseDir, task.specId);
|
||||
const hasWorktree = existsSync(worktreePath);
|
||||
|
||||
if (approved) {
|
||||
// Write approval to QA report
|
||||
const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT);
|
||||
@@ -204,15 +223,60 @@ export function registerTaskExecutionHandlers(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Write feedback for QA fixer
|
||||
const fixRequestPath = path.join(specDir, 'QA_FIX_REQUEST.md');
|
||||
// Reset and discard all changes from worktree merge in main
|
||||
// The worktree still has all changes, so nothing is lost
|
||||
if (hasWorktree) {
|
||||
// Step 1: Unstage all changes
|
||||
const resetResult = spawnSync('git', ['reset', 'HEAD'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
if (resetResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Unstaged changes in main');
|
||||
}
|
||||
|
||||
// Step 2: Discard all working tree changes (restore to pre-merge state)
|
||||
const checkoutResult = spawnSync('git', ['checkout', '--', '.'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
if (checkoutResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Discarded working tree changes in main');
|
||||
}
|
||||
|
||||
// Step 3: Clean untracked files that came from the merge
|
||||
const cleanResult = spawnSync('git', ['clean', '-fd'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
if (cleanResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Cleaned untracked files in main');
|
||||
}
|
||||
|
||||
console.log('[TASK_REVIEW] Main branch restored to pre-merge state');
|
||||
}
|
||||
|
||||
// Write feedback for QA fixer - write to WORKTREE spec dir if it exists
|
||||
// The QA process runs in the worktree where the build and implementation_plan.json are
|
||||
const targetSpecDir = hasWorktree ? worktreeSpecDir : specDir;
|
||||
const fixRequestPath = path.join(targetSpecDir, 'QA_FIX_REQUEST.md');
|
||||
|
||||
console.warn('[TASK_REVIEW] Writing QA fix request to:', fixRequestPath);
|
||||
console.warn('[TASK_REVIEW] hasWorktree:', hasWorktree, 'worktreePath:', worktreePath);
|
||||
|
||||
writeFileSync(
|
||||
fixRequestPath,
|
||||
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n`
|
||||
);
|
||||
|
||||
// Restart QA process with dev mode
|
||||
agentManager.startQAProcess(taskId, project.path, task.specId);
|
||||
// Restart QA process - use worktree path if it exists, otherwise main project
|
||||
// The QA process needs to run where the implementation_plan.json with completed subtasks is
|
||||
const qaProjectPath = hasWorktree ? worktreePath : project.path;
|
||||
console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath);
|
||||
agentManager.startQAProcess(taskId, qaProjectPath, task.specId);
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
@@ -265,6 +329,37 @@ export function registerTaskExecutionHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status transition - 'human_review' requires actual work to have been done
|
||||
// This prevents tasks from being incorrectly marked as ready for review when execution failed
|
||||
if (status === 'human_review') {
|
||||
const specsBaseDirForValidation = getSpecsDir(project.autoBuildPath);
|
||||
const specDirForValidation = path.join(
|
||||
project.path,
|
||||
specsBaseDirForValidation,
|
||||
task.specId
|
||||
);
|
||||
const specFilePath = path.join(specDirForValidation, AUTO_BUILD_PATHS.SPEC_FILE);
|
||||
|
||||
// Check if spec.md exists and has meaningful content (at least 100 chars)
|
||||
const MIN_SPEC_CONTENT_LENGTH = 100;
|
||||
let specContent = '';
|
||||
try {
|
||||
if (existsSync(specFilePath)) {
|
||||
specContent = readFileSync(specFilePath, 'utf-8');
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors - treat as empty spec
|
||||
}
|
||||
|
||||
if (!specContent || specContent.length < MIN_SPEC_CONTENT_LENGTH) {
|
||||
console.warn(`[TASK_UPDATE_STATUS] Blocked attempt to set status 'human_review' for task ${taskId}. No spec has been created yet.`);
|
||||
return {
|
||||
success: false,
|
||||
error: "Cannot move to human review - no spec has been created yet. The task must complete processing before review."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get the spec directory
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specDir = path.join(
|
||||
@@ -334,6 +429,20 @@ export function registerTaskExecutionHandlers(
|
||||
return { success: false, error: gitStatusCheck.error || 'Git repository required' };
|
||||
}
|
||||
|
||||
// Check authentication before auto-starting
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[TASK_UPDATE_STATUS] No valid authentication for active profile');
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.TASK_ERROR,
|
||||
taskId,
|
||||
'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account, or set an OAuth token.'
|
||||
);
|
||||
}
|
||||
return { success: false, error: 'Claude authentication required' };
|
||||
}
|
||||
|
||||
console.warn('[TASK_UPDATE_STATUS] Auto-starting task:', taskId);
|
||||
|
||||
// Start file watcher for this task
|
||||
@@ -562,6 +671,23 @@ export function registerTaskExecutionHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
// Check authentication before auto-restarting
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[Recovery] Auth check failed, cannot auto-restart task');
|
||||
// Recovery succeeded but we can't restart without auth
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
recovered: true,
|
||||
newStatus,
|
||||
message: 'Task recovered but cannot restart: Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account.',
|
||||
autoRestarted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Set status to in_progress for the restart
|
||||
newStatus = 'in_progress';
|
||||
|
||||
@@ -3,12 +3,13 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS } from '../../../shared/constants';
|
||||
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readdirSync, statSync } from 'fs';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { execSync, spawn, spawnSync } from 'child_process';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { PythonEnvManager } from '../../python-env-manager';
|
||||
import { getEffectiveSourcePath } from '../../auto-claude-updater';
|
||||
import { getProfileEnv } from '../../rate-limit-detector';
|
||||
import { findTaskAndProject } from './shared';
|
||||
import { findPythonCommand, parsePythonCommand } from '../../python-detector';
|
||||
|
||||
/**
|
||||
* Register worktree management handlers
|
||||
@@ -48,14 +49,14 @@ export function registerWorktreeHandlers(
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
// Get base branch (usually main or master)
|
||||
// Get base branch - the current branch in the main project (where changes will be merged)
|
||||
// This matches the Python merge logic which merges into the user's current branch
|
||||
let baseBranch = 'main';
|
||||
try {
|
||||
// Try to get the default branch
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
}).trim().replace('origin/', '');
|
||||
}).trim();
|
||||
} catch {
|
||||
baseBranch = 'main';
|
||||
}
|
||||
@@ -144,13 +145,13 @@ export function registerWorktreeHandlers(
|
||||
return { success: false, error: 'No worktree found for this task' };
|
||||
}
|
||||
|
||||
// Get base branch
|
||||
// Get base branch - the current branch in the main project (where changes will be merged)
|
||||
let baseBranch = 'main';
|
||||
try {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
}).trim().replace('origin/', '');
|
||||
}).trim();
|
||||
} catch {
|
||||
baseBranch = 'main';
|
||||
}
|
||||
@@ -272,6 +273,31 @@ export function registerWorktreeHandlers(
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
debug('Worktree path:', worktreePath, 'exists:', existsSync(worktreePath));
|
||||
|
||||
// Check if changes are already staged (for stage-only mode)
|
||||
if (options?.noCommit) {
|
||||
const stagedResult = spawnSync('git', ['diff', '--staged', '--name-only'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (stagedResult.status === 0 && stagedResult.stdout?.trim()) {
|
||||
const stagedFiles = stagedResult.stdout.trim().split('\n');
|
||||
debug('Changes already staged:', stagedFiles.length, 'files');
|
||||
// Return success - changes are already staged
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
merged: false,
|
||||
message: `Changes already staged (${stagedFiles.length} files). Review with git diff --staged.`,
|
||||
staged: true,
|
||||
alreadyStaged: true,
|
||||
projectPath: project.path
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get git status before merge
|
||||
try {
|
||||
const gitStatusBefore = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' });
|
||||
@@ -294,7 +320,7 @@ export function registerWorktreeHandlers(
|
||||
args.push('--no-commit');
|
||||
}
|
||||
|
||||
const pythonPath = pythonEnvManager.getPythonPath() || 'python3';
|
||||
const pythonPath = pythonEnvManager.getPythonPath() || findPythonCommand() || 'python';
|
||||
debug('Running command:', pythonPath, args.join(' '));
|
||||
debug('Working directory:', sourcePath);
|
||||
|
||||
@@ -310,7 +336,9 @@ export function registerWorktreeHandlers(
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let resolved = false;
|
||||
|
||||
const mergeProcess = spawn(pythonPath, args, {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const mergeProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd: sourcePath,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -402,12 +430,90 @@ export function registerWorktreeHandlers(
|
||||
if (code === 0) {
|
||||
const isStageOnly = options?.noCommit === true;
|
||||
|
||||
// For stage-only: keep in human_review so user commits manually
|
||||
// For full merge: mark as done
|
||||
const newStatus = isStageOnly ? 'human_review' : 'done';
|
||||
const planStatus = isStageOnly ? 'review' : 'completed';
|
||||
// Verify changes were actually staged when stage-only mode is requested
|
||||
// This prevents false positives when merge was already committed previously
|
||||
let hasActualStagedChanges = false;
|
||||
let mergeAlreadyCommitted = false;
|
||||
|
||||
debug('Merge successful. isStageOnly:', isStageOnly, 'newStatus:', newStatus);
|
||||
if (isStageOnly) {
|
||||
try {
|
||||
const gitDiffStaged = execSync('git diff --staged --stat', { cwd: project.path, encoding: 'utf-8' });
|
||||
hasActualStagedChanges = gitDiffStaged.trim().length > 0;
|
||||
debug('Stage-only verification: hasActualStagedChanges:', hasActualStagedChanges);
|
||||
|
||||
if (!hasActualStagedChanges) {
|
||||
// Check if worktree branch was already merged (merge commit exists)
|
||||
const specBranch = `auto-claude/${task.specId}`;
|
||||
try {
|
||||
// Check if current branch contains all commits from spec branch
|
||||
const mergeBaseResult = execSync(
|
||||
`git merge-base --is-ancestor ${specBranch} HEAD 2>/dev/null && echo "merged" || echo "not-merged"`,
|
||||
{ cwd: project.path, encoding: 'utf-8' }
|
||||
).trim();
|
||||
mergeAlreadyCommitted = mergeBaseResult === 'merged';
|
||||
debug('Merge already committed check:', mergeAlreadyCommitted);
|
||||
} catch {
|
||||
// Branch may not exist or other error - assume not merged
|
||||
debug('Could not check merge status, assuming not merged');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Failed to verify staged changes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine actual status based on verification
|
||||
let newStatus: string;
|
||||
let planStatus: string;
|
||||
let message: string;
|
||||
let staged: boolean;
|
||||
|
||||
if (isStageOnly && !hasActualStagedChanges && mergeAlreadyCommitted) {
|
||||
// Stage-only was requested but merge was already committed previously
|
||||
// Mark as done since changes are already in the branch
|
||||
newStatus = 'done';
|
||||
planStatus = 'completed';
|
||||
message = 'Changes were already merged and committed. Task marked as done.';
|
||||
staged = false;
|
||||
debug('Stage-only requested but merge already committed. Marking as done.');
|
||||
} else if (isStageOnly && !hasActualStagedChanges) {
|
||||
// Stage-only was requested but no changes to stage (and not committed)
|
||||
// This could mean nothing to merge or an error - keep in human_review for investigation
|
||||
newStatus = 'human_review';
|
||||
planStatus = 'review';
|
||||
message = 'No changes to stage. The worktree may have no differences from the current branch.';
|
||||
staged = false;
|
||||
debug('Stage-only requested but no changes to stage.');
|
||||
} else if (isStageOnly) {
|
||||
// Stage-only with actual staged changes - expected success case
|
||||
newStatus = 'human_review';
|
||||
planStatus = 'review';
|
||||
message = 'Changes staged in main project. Review with git status and commit when ready.';
|
||||
staged = true;
|
||||
} else {
|
||||
// Full merge (not stage-only)
|
||||
newStatus = 'done';
|
||||
planStatus = 'completed';
|
||||
message = 'Changes merged successfully';
|
||||
staged = false;
|
||||
}
|
||||
|
||||
debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged);
|
||||
|
||||
// Read suggested commit message if staging succeeded
|
||||
let suggestedCommitMessage: string | undefined;
|
||||
if (staged) {
|
||||
const commitMsgPath = path.join(specDir, 'suggested_commit_message.txt');
|
||||
try {
|
||||
if (existsSync(commitMsgPath)) {
|
||||
const { readFileSync } = require('fs');
|
||||
suggestedCommitMessage = readFileSync(commitMsgPath, 'utf-8').trim();
|
||||
debug('Read suggested commit message:', suggestedCommitMessage?.substring(0, 100));
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Failed to read suggested commit message:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the status change to implementation_plan.json
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
@@ -419,7 +525,7 @@ export function registerWorktreeHandlers(
|
||||
plan.status = newStatus;
|
||||
plan.planStatus = planStatus;
|
||||
plan.updated_at = new Date().toISOString();
|
||||
if (isStageOnly) {
|
||||
if (staged) {
|
||||
plan.stagedAt = new Date().toISOString();
|
||||
plan.stagedInMainProject = true;
|
||||
}
|
||||
@@ -434,17 +540,14 @@ export function registerWorktreeHandlers(
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, newStatus);
|
||||
}
|
||||
|
||||
const message = isStageOnly
|
||||
? 'Changes staged in main project. Review with git status and commit when ready.'
|
||||
: 'Changes merged successfully';
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
message,
|
||||
staged: isStageOnly,
|
||||
projectPath: isStageOnly ? project.path : undefined
|
||||
staged,
|
||||
projectPath: staged ? project.path : undefined,
|
||||
suggestedCommitMessage
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -533,15 +636,17 @@ export function registerWorktreeHandlers(
|
||||
const gitStatus = execSync('git status --porcelain', {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
});
|
||||
|
||||
if (gitStatus) {
|
||||
if (gitStatus && gitStatus.trim()) {
|
||||
// Parse the status output to get file names
|
||||
uncommittedFiles = gitStatus.split('\n')
|
||||
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
||||
uncommittedFiles = gitStatus
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => line.substring(3).trim()); // Remove status prefix (e.g., "M ", " M ", "?? ")
|
||||
.map(line => line.substring(3).trim()); // Skip 2 status chars + 1 space, trim any trailing whitespace
|
||||
|
||||
hasUncommittedChanges = uncommittedFiles.length > 0;
|
||||
console.warn('[IPC] Uncommitted changes detected:', uncommittedFiles.length, 'files');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[IPC] Failed to check git status:', e);
|
||||
@@ -561,14 +666,16 @@ export function registerWorktreeHandlers(
|
||||
'--merge-preview'
|
||||
];
|
||||
|
||||
const pythonPath = pythonEnvManager.getPythonPath() || 'python3';
|
||||
const pythonPath = pythonEnvManager.getPythonPath() || findPythonCommand() || 'python';
|
||||
console.warn('[IPC] Running merge preview:', pythonPath, args.join(' '));
|
||||
|
||||
// Get profile environment for consistency
|
||||
const previewProfileEnv = getProfileEnv();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const previewProcess = spawn(pythonPath, args, {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const previewProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd: sourcePath,
|
||||
env: { ...process.env, ...previewProfileEnv, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1', DEBUG: 'true' }
|
||||
});
|
||||
@@ -774,13 +881,13 @@ export function registerWorktreeHandlers(
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
// Get base branch
|
||||
// Get base branch - the current branch in the main project (where changes will be merged)
|
||||
let baseBranch = 'main';
|
||||
try {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
}).trim().replace('origin/', '');
|
||||
}).trim();
|
||||
} catch {
|
||||
baseBranch = 'main';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||
import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
|
||||
|
||||
|
||||
/**
|
||||
@@ -162,14 +164,108 @@ export function registerTerminalHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] ========== PROFILE SWITCH START ==========');
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Requested profile ID:', profileId);
|
||||
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const previousProfile = profileManager.getActiveProfile();
|
||||
const previousProfileId = previousProfile.id;
|
||||
const newProfile = profileManager.getProfile(profileId);
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Previous profile:', {
|
||||
id: previousProfile.id,
|
||||
name: previousProfile.name,
|
||||
hasOAuthToken: !!previousProfile.oauthToken,
|
||||
isDefault: previousProfile.isDefault
|
||||
});
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] New profile:', newProfile ? {
|
||||
id: newProfile.id,
|
||||
name: newProfile.name,
|
||||
hasOAuthToken: !!newProfile.oauthToken,
|
||||
isDefault: newProfile.isDefault
|
||||
} : 'NOT FOUND');
|
||||
|
||||
const success = profileManager.setActiveProfile(profileId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] setActiveProfile result:', success);
|
||||
|
||||
if (!success) {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Profile not found, aborting');
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
// If the profile actually changed, restart Claude in active terminals
|
||||
// This ensures existing Claude sessions use the new profile's OAuth token
|
||||
const profileChanged = previousProfileId !== profileId;
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Profile changed:', profileChanged, {
|
||||
previousProfileId,
|
||||
newProfileId: profileId
|
||||
});
|
||||
|
||||
if (profileChanged) {
|
||||
const activeTerminalIds = terminalManager.getActiveTerminalIds();
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Active terminal IDs:', activeTerminalIds);
|
||||
|
||||
const switchPromises: Promise<void>[] = [];
|
||||
const terminalsInClaudeMode: string[] = [];
|
||||
const terminalsNotInClaudeMode: string[] = [];
|
||||
|
||||
for (const terminalId of activeTerminalIds) {
|
||||
const isClaudeMode = terminalManager.isClaudeMode(terminalId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal check:', {
|
||||
terminalId,
|
||||
isClaudeMode
|
||||
});
|
||||
|
||||
if (isClaudeMode) {
|
||||
terminalsInClaudeMode.push(terminalId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Queuing terminal for profile switch:', terminalId);
|
||||
switchPromises.push(
|
||||
terminalManager.switchClaudeProfile(terminalId, profileId)
|
||||
.then(() => {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal profile switch SUCCESS:', terminalId);
|
||||
})
|
||||
.catch((err) => {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal profile switch FAILED:', terminalId, err);
|
||||
throw err; // Re-throw so Promise.allSettled correctly reports rejections
|
||||
})
|
||||
);
|
||||
} else {
|
||||
terminalsNotInClaudeMode.push(terminalId);
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal summary:', {
|
||||
total: activeTerminalIds.length,
|
||||
inClaudeMode: terminalsInClaudeMode.length,
|
||||
notInClaudeMode: terminalsNotInClaudeMode.length,
|
||||
terminalsToSwitch: terminalsInClaudeMode,
|
||||
terminalsSkipped: terminalsNotInClaudeMode
|
||||
});
|
||||
|
||||
// Wait for all switches to complete (but don't fail the main operation if some fail)
|
||||
if (switchPromises.length > 0) {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Waiting for', switchPromises.length, 'terminal switches...');
|
||||
const results = await Promise.allSettled(switchPromises);
|
||||
const fulfilled = results.filter(r => r.status === 'fulfilled').length;
|
||||
const rejected = results.filter(r => r.status === 'rejected').length;
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Switch results:', {
|
||||
total: results.length,
|
||||
fulfilled,
|
||||
rejected
|
||||
});
|
||||
} else {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] No terminals in Claude mode to switch');
|
||||
}
|
||||
} else {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Same profile selected, no terminal switches needed');
|
||||
}
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] ========== PROFILE SWITCH COMPLETE ==========');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] EXCEPTION:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active Claude profile'
|
||||
@@ -208,7 +304,7 @@ export function registerTerminalHandlers(
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
console.warn('[IPC] Created config directory:', profile.configDir);
|
||||
debugLog('[IPC] Created config directory:', profile.configDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +313,7 @@ export function registerTerminalHandlers(
|
||||
const terminalId = `claude-login-${profileId}-${Date.now()}`;
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
|
||||
console.warn('[IPC] Initializing Claude profile:', {
|
||||
debugLog('[IPC] Initializing Claude profile:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
configDir: profile.configDir,
|
||||
@@ -231,16 +327,25 @@ export function registerTerminalHandlers(
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Build the login command with the profile's config dir
|
||||
// Use export to ensure the variable persists, then run setup-token
|
||||
// Use platform-specific syntax and escaping for environment variables
|
||||
let loginCommand: string;
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
// Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
if (process.platform === 'win32') {
|
||||
// SECURITY: Use Windows-specific escaping for cmd.exe
|
||||
const escapedConfigDir = escapeShellArgWindows(profile.configDir);
|
||||
// Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion
|
||||
loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`;
|
||||
} else {
|
||||
// SECURITY: Use POSIX escaping for bash/zsh
|
||||
const escapedConfigDir = escapeShellArg(profile.configDir);
|
||||
// Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
}
|
||||
} else {
|
||||
loginCommand = 'claude setup-token';
|
||||
}
|
||||
|
||||
console.warn('[IPC] Sending login command to terminal:', loginCommand);
|
||||
debugLog('[IPC] Sending login command to terminal:', loginCommand);
|
||||
|
||||
// Write the login command to the terminal
|
||||
terminalManager.write(terminalId, `${loginCommand}\r`);
|
||||
@@ -263,7 +368,7 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to initialize Claude profile:', error);
|
||||
debugError('[IPC] Failed to initialize Claude profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initialize Claude profile'
|
||||
@@ -284,7 +389,7 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to set OAuth token:', error);
|
||||
debugError('[IPC] Failed to set OAuth token:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set OAuth token'
|
||||
@@ -569,5 +674,5 @@ export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): voi
|
||||
mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification);
|
||||
});
|
||||
|
||||
console.warn('[terminal-handlers] Usage monitor event forwarding initialized');
|
||||
debugLog('[terminal-handlers] Usage monitor event forwarding initialized');
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
|
||||
* Debug logging - only logs when DEBUG=true or in development mode
|
||||
*/
|
||||
const DEBUG = process.env.AUTO_CLAUDE_DEBUG === 'true' || process.env.AUTO_CLAUDE_DEBUG === '1';
|
||||
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
function debug(message: string, data?: Record<string, unknown>): void {
|
||||
if (DEBUG) {
|
||||
|
||||
@@ -243,8 +243,8 @@ export class ProjectStore {
|
||||
if (existsSync(specFilePath)) {
|
||||
try {
|
||||
const content = readFileSync(specFilePath, 'utf-8');
|
||||
// Extract first paragraph after "## Overview"
|
||||
const overviewMatch = content.match(/## Overview\s*\n\n([^\n#]+)/);
|
||||
// Extract first paragraph after "## Overview" - handle both with and without blank line
|
||||
const overviewMatch = content.match(/## Overview\s*\n+([^\n#]+)/);
|
||||
if (overviewMatch) {
|
||||
description = overviewMatch[1].trim();
|
||||
}
|
||||
@@ -258,6 +258,42 @@ export class ProjectStore {
|
||||
description = plan.description;
|
||||
}
|
||||
|
||||
// Fallback: read description from requirements.json if still not found
|
||||
if (!description) {
|
||||
const requirementsPath = path.join(specPath, AUTO_BUILD_PATHS.REQUIREMENTS);
|
||||
if (existsSync(requirementsPath)) {
|
||||
try {
|
||||
const reqContent = readFileSync(requirementsPath, 'utf-8');
|
||||
const requirements = JSON.parse(reqContent);
|
||||
if (requirements.task_description) {
|
||||
// Extract a clean summary from task_description (first line or first ~200 chars)
|
||||
const taskDesc = requirements.task_description;
|
||||
const firstLine = taskDesc.split('\n')[0].trim();
|
||||
// If the first line is a title like "Investigate GitHub Issue #36", use the next meaningful line
|
||||
if (firstLine.toLowerCase().startsWith('investigate') && taskDesc.includes('\n\n')) {
|
||||
const sections = taskDesc.split('\n\n');
|
||||
// Find the first paragraph that's not a title
|
||||
for (const section of sections) {
|
||||
const trimmed = section.trim();
|
||||
// Skip headers and short lines
|
||||
if (trimmed.startsWith('#') || trimmed.length < 20) continue;
|
||||
// Skip the "Please analyze" instruction at the end
|
||||
if (trimmed.startsWith('Please analyze')) continue;
|
||||
description = trimmed.substring(0, 200).split('\n')[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If still no description, use a shortened version of task_description
|
||||
if (!description) {
|
||||
description = firstLine.substring(0, 150);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read task metadata
|
||||
const metadataPath = path.join(specPath, 'task_metadata.json');
|
||||
let metadata: TaskMetadata | undefined;
|
||||
@@ -290,11 +326,30 @@ export class ProjectStore {
|
||||
const stagedInMainProject = planWithStaged?.stagedInMainProject;
|
||||
const stagedAt = planWithStaged?.stagedAt;
|
||||
|
||||
// Determine title - check if feature looks like a spec ID (e.g., "054-something-something")
|
||||
let title = plan?.feature || plan?.title || dir.name;
|
||||
const looksLikeSpecId = /^\d{3}-/.test(title);
|
||||
if (looksLikeSpecId && existsSync(specFilePath)) {
|
||||
try {
|
||||
const specContent = readFileSync(specFilePath, 'utf-8');
|
||||
// Extract title from first # line, handling patterns like:
|
||||
// "# Quick Spec: Title" -> "Title"
|
||||
// "# Specification: Title" -> "Title"
|
||||
// "# Title" -> "Title"
|
||||
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
} catch {
|
||||
// Keep the original title on error
|
||||
}
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
id: dir.name, // Use spec directory name as ID
|
||||
specId: dir.name,
|
||||
projectId,
|
||||
title: plan?.feature || dir.name,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
reviewReason,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Detect and return the best available Python command.
|
||||
* Tries multiple candidates and returns the first one that works with Python 3.
|
||||
*
|
||||
* @returns The Python command to use, or null if none found
|
||||
*/
|
||||
export function findPythonCommand(): string | null {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// On Windows, try py launcher first (most reliable), then python, then python3
|
||||
// On Unix, try python3 first, then python
|
||||
const candidates = isWindows
|
||||
? ['py -3', 'python', 'python3', 'py']
|
||||
: ['python3', 'python'];
|
||||
|
||||
for (const cmd of candidates) {
|
||||
try {
|
||||
const version = execSync(`${cmd} --version`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
}).toString();
|
||||
|
||||
if (version.includes('Python 3')) {
|
||||
return cmd;
|
||||
}
|
||||
} catch {
|
||||
// Command not found or errored, try next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to platform-specific default
|
||||
return isWindows ? 'python' : 'python3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default Python command for the current platform.
|
||||
* This is a synchronous fallback that doesn't test if Python actually exists.
|
||||
*
|
||||
* @returns The default Python command for this platform
|
||||
*/
|
||||
export function getDefaultPythonCommand(): string {
|
||||
return process.platform === 'win32' ? 'python' : 'python3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Python command string into command and base arguments.
|
||||
* Handles space-separated commands like "py -3".
|
||||
*
|
||||
* @param pythonPath - The Python command string (e.g., "python3", "py -3")
|
||||
* @returns Tuple of [command, baseArgs] ready for use with spawn()
|
||||
*/
|
||||
export function parsePythonCommand(pythonPath: string): [string, string[]] {
|
||||
const parts = pythonPath.split(' ');
|
||||
const command = parts[0];
|
||||
const baseArgs = parts.slice(1);
|
||||
return [command, baseArgs];
|
||||
}
|
||||
@@ -37,16 +37,11 @@ export class PythonEnvManager extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Get the path to pip in the venv
|
||||
* Returns null - we use python -m pip instead for better compatibility
|
||||
* @deprecated Use getVenvPythonPath() with -m pip instead
|
||||
*/
|
||||
private getVenvPipPath(): string | null {
|
||||
if (!this.autoBuildSourcePath) return null;
|
||||
|
||||
const venvPip =
|
||||
process.platform === 'win32'
|
||||
? path.join(this.autoBuildSourcePath, '.venv', 'Scripts', 'pip.exe')
|
||||
: path.join(this.autoBuildSourcePath, '.venv', 'bin', 'pip');
|
||||
|
||||
return venvPip;
|
||||
return null; // Not used - we use python -m pip
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,16 +176,54 @@ export class PythonEnvManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install dependencies from requirements.txt
|
||||
* Bootstrap pip in the venv using ensurepip
|
||||
*/
|
||||
private async bootstrapPip(): Promise<boolean> {
|
||||
const venvPython = this.getVenvPythonPath();
|
||||
if (!venvPython || !existsSync(venvPython)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.warn('[PythonEnvManager] Bootstrapping pip...');
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(venvPython, ['-m', 'ensurepip'], {
|
||||
cwd: this.autoBuildSourcePath!,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.warn('[PythonEnvManager] Pip bootstrapped successfully');
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error('[PythonEnvManager] Failed to bootstrap pip:', stderr);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error('[PythonEnvManager] Error bootstrapping pip:', err);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install dependencies from requirements.txt using python -m pip
|
||||
*/
|
||||
private async installDeps(): Promise<boolean> {
|
||||
if (!this.autoBuildSourcePath) return false;
|
||||
|
||||
const venvPip = this.getVenvPipPath();
|
||||
const venvPython = this.getVenvPythonPath();
|
||||
const requirementsPath = path.join(this.autoBuildSourcePath, 'requirements.txt');
|
||||
|
||||
if (!venvPip || !existsSync(venvPip)) {
|
||||
this.emit('error', 'Pip not found in virtual environment');
|
||||
if (!venvPython || !existsSync(venvPython)) {
|
||||
this.emit('error', 'Python not found in virtual environment');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -199,11 +232,15 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bootstrap pip first if needed
|
||||
await this.bootstrapPip();
|
||||
|
||||
this.emit('status', 'Installing Python dependencies (this may take a minute)...');
|
||||
console.warn('[PythonEnvManager] Installing dependencies from:', requirementsPath);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(venvPip, ['install', '-r', requirementsPath], {
|
||||
// Use python -m pip for better compatibility across Python versions
|
||||
const proc = spawn(venvPython, ['-m', 'pip', 'install', '-r', requirementsPath], {
|
||||
cwd: this.autoBuildSourcePath!,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
@@ -22,6 +22,26 @@ const RATE_LIMIT_INDICATORS = [
|
||||
/too\s*many\s*requests/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns that indicate authentication failures
|
||||
* These patterns detect when Claude CLI/SDK fails due to missing or invalid auth
|
||||
*/
|
||||
const AUTH_FAILURE_PATTERNS = [
|
||||
/authentication\s*(is\s*)?required/i,
|
||||
/not\s*(yet\s*)?authenticated/i,
|
||||
/login\s*(is\s*)?required/i,
|
||||
/oauth\s*token\s*(is\s*)?(invalid|expired|missing)/i,
|
||||
/unauthorized/i,
|
||||
/please\s*(log\s*in|login|authenticate)/i,
|
||||
/invalid\s*(credentials|token|api\s*key)/i,
|
||||
/auth(entication)?\s*(failed|error|failure)/i,
|
||||
/session\s*(expired|invalid)/i,
|
||||
/access\s*denied/i,
|
||||
/permission\s*denied/i,
|
||||
/401\s*unauthorized/i,
|
||||
/credentials\s*(are\s*)?(missing|invalid|expired)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Result of rate limit detection
|
||||
*/
|
||||
@@ -43,6 +63,22 @@ export interface RateLimitDetectionResult {
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of authentication failure detection
|
||||
*/
|
||||
export interface AuthFailureDetectionResult {
|
||||
/** Whether an authentication failure was detected */
|
||||
isAuthFailure: boolean;
|
||||
/** The profile ID that failed to authenticate (if known) */
|
||||
profileId?: string;
|
||||
/** The type of auth failure detected */
|
||||
failureType?: 'missing' | 'invalid' | 'expired' | 'unknown';
|
||||
/** User-friendly message describing the failure */
|
||||
message?: string;
|
||||
/** Original error message from the process output */
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify rate limit type based on reset time string
|
||||
*/
|
||||
@@ -132,6 +168,80 @@ export function extractResetTime(output: string): string | null {
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the type of authentication failure based on the error message
|
||||
*/
|
||||
function classifyAuthFailureType(output: string): 'missing' | 'invalid' | 'expired' | 'unknown' {
|
||||
const lowerOutput = output.toLowerCase();
|
||||
|
||||
if (/missing|not\s*(yet\s*)?authenticated|required/.test(lowerOutput)) {
|
||||
return 'missing';
|
||||
}
|
||||
if (/expired|session\s*expired/.test(lowerOutput)) {
|
||||
return 'expired';
|
||||
}
|
||||
if (/invalid|unauthorized|denied/.test(lowerOutput)) {
|
||||
return 'invalid';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for the authentication failure
|
||||
*/
|
||||
function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' | 'unknown'): string {
|
||||
switch (failureType) {
|
||||
case 'missing':
|
||||
return 'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account.';
|
||||
case 'expired':
|
||||
return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.';
|
||||
case 'invalid':
|
||||
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect authentication failure from output (stdout + stderr combined)
|
||||
*/
|
||||
export function detectAuthFailure(
|
||||
output: string,
|
||||
profileId?: string
|
||||
): AuthFailureDetectionResult {
|
||||
// First, make sure this isn't a rate limit error (those should be handled separately)
|
||||
if (detectRateLimit(output).isRateLimited) {
|
||||
return { isAuthFailure: false };
|
||||
}
|
||||
|
||||
// Check for authentication failure patterns
|
||||
for (const pattern of AUTH_FAILURE_PATTERNS) {
|
||||
if (pattern.test(output)) {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const effectiveProfileId = profileId || profileManager.getActiveProfile().id;
|
||||
const failureType = classifyAuthFailureType(output);
|
||||
|
||||
return {
|
||||
isAuthFailure: true,
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getAuthFailureMessage(failureType),
|
||||
originalError: output
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isAuthFailure: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output contains authentication failure error
|
||||
*/
|
||||
export function isAuthFailureError(output: string): boolean {
|
||||
return detectAuthFailure(output).isAuthFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for a specific Claude profile.
|
||||
* Uses OAuth token (CLAUDE_CODE_OAUTH_TOKEN) if available, otherwise falls back to CLAUDE_CONFIG_DIR.
|
||||
|
||||
@@ -32,6 +32,7 @@ export class TaskLogService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Load task logs from a single spec directory
|
||||
* Returns cached logs if the file is corrupted (e.g., mid-write by Python backend)
|
||||
*/
|
||||
loadLogsFromPath(specDir: string): TaskLogs | null {
|
||||
const logFile = path.join(specDir, 'task_logs.json');
|
||||
@@ -43,8 +44,16 @@ export class TaskLogService extends EventEmitter {
|
||||
try {
|
||||
const content = readFileSync(logFile, 'utf-8');
|
||||
const logs = JSON.parse(content) as TaskLogs;
|
||||
this.logCache.set(specDir, logs);
|
||||
return logs;
|
||||
} catch (error) {
|
||||
// JSON parse error - file may be mid-write, return cached version if available
|
||||
const cached = this.logCache.get(specDir);
|
||||
if (cached) {
|
||||
// Silently return cached version - this is expected during concurrent access
|
||||
return cached;
|
||||
}
|
||||
// Only log if we have no cached fallback
|
||||
console.error(`[TaskLogService] Failed to load logs from ${logFile}:`, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
|
||||
import { findPythonCommand, parsePythonCommand } from './python-detector';
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
|
||||
* Debug logging - only logs when DEBUG=true or in development mode
|
||||
*/
|
||||
const DEBUG = process.env.AUTO_CLAUDE_DEBUG === 'true' || process.env.AUTO_CLAUDE_DEBUG === '1';
|
||||
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
function debug(...args: unknown[]): void {
|
||||
if (DEBUG) {
|
||||
@@ -20,7 +21,8 @@ function debug(...args: unknown[]): void {
|
||||
* Service for generating terminal names from commands using Claude AI
|
||||
*/
|
||||
export class TerminalNameGenerator extends EventEmitter {
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
constructor() {
|
||||
@@ -130,7 +132,9 @@ export class TerminalNameGenerator extends EventEmitter {
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
|
||||
cwd: autoBuildSource,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import * as OutputParser from './output-parser';
|
||||
import * as SessionHandler from './session-handler';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape';
|
||||
import type {
|
||||
TerminalProcess,
|
||||
WindowGetter,
|
||||
@@ -92,9 +94,11 @@ export function handleOAuthToken(
|
||||
console.warn('[ClaudeIntegration] OAuth token detected, length:', token.length);
|
||||
|
||||
const email = OutputParser.extractEmail(terminal.outputBuffer);
|
||||
const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+)-/);
|
||||
// Match both custom profiles (profile-123456) and the default profile
|
||||
const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+|default)-/);
|
||||
|
||||
if (profileIdMatch) {
|
||||
// Save to specific profile (profile login terminal)
|
||||
const profileId = profileIdMatch[1];
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setProfileToken(profileId, token, email || undefined);
|
||||
@@ -116,16 +120,56 @@ export function handleOAuthToken(
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token to profile:', profileId);
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration] OAuth token detected but not in a profile login terminal');
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
email,
|
||||
success: false,
|
||||
message: 'Token detected but no profile associated with this terminal',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
// No profile-specific terminal, save to active profile (GitHub OAuth flow, etc.)
|
||||
console.warn('[ClaudeIntegration] OAuth token detected in non-profile terminal, saving to active profile');
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
// Defensive null check for active profile
|
||||
if (!activeProfile) {
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token: no active profile found');
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: undefined,
|
||||
email,
|
||||
success: false,
|
||||
message: 'No active profile found',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const success = profileManager.setProfileToken(activeProfile.id, token, email || undefined);
|
||||
|
||||
if (success) {
|
||||
console.warn('[ClaudeIntegration] OAuth token auto-saved to active profile:', activeProfile.name);
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: activeProfile.id,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
} else {
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token to active profile:', activeProfile.name);
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: activeProfile?.id,
|
||||
email,
|
||||
success: false,
|
||||
message: 'Failed to save token to active profile',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +205,11 @@ export function invokeClaude(
|
||||
getWindow: WindowGetter,
|
||||
onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void
|
||||
): void {
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE START ==========');
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Terminal ID:', terminal.id);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Requested profile ID:', profileId);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd);
|
||||
|
||||
terminal.isClaudeMode = true;
|
||||
terminal.claudeSessionId = undefined;
|
||||
|
||||
@@ -175,31 +224,70 @@ export function invokeClaude(
|
||||
const previousProfileId = terminal.claudeProfileId;
|
||||
terminal.claudeProfileId = activeProfile?.id;
|
||||
|
||||
const cwdCommand = cwd ? `cd "${cwd}" && ` : '';
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Profile resolution:', {
|
||||
previousProfileId,
|
||||
newProfileId: activeProfile?.id,
|
||||
profileName: activeProfile?.name,
|
||||
hasOAuthToken: !!activeProfile?.oauthToken,
|
||||
isDefault: activeProfile?.isDefault
|
||||
});
|
||||
|
||||
// Use safe shell escaping to prevent command injection
|
||||
const cwdCommand = buildCdCommand(cwd);
|
||||
const needsEnvOverride = profileId && profileId !== previousProfileId;
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', {
|
||||
profileIdProvided: !!profileId,
|
||||
previousProfileId,
|
||||
needsEnvOverride
|
||||
});
|
||||
|
||||
if (needsEnvOverride && activeProfile && !activeProfile.isDefault) {
|
||||
const token = profileManager.getProfileToken(activeProfile.id);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Token retrieval:', {
|
||||
hasToken: !!token,
|
||||
tokenLength: token?.length
|
||||
});
|
||||
|
||||
if (token) {
|
||||
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile);
|
||||
fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 });
|
||||
|
||||
terminal.pty.write(`${cwdCommand}source "${tempFile}" && rm -f "${tempFile}" && claude\r`);
|
||||
console.warn('[ClaudeIntegration] Switching to Claude profile:', activeProfile.name, '(via secure temp file)');
|
||||
// Clear terminal and run command without adding to shell history:
|
||||
// - HISTFILE= disables history file writing for the current command
|
||||
// - HISTCONTROL=ignorespace causes commands starting with space to be ignored
|
||||
// - Leading space ensures the command is ignored even if HISTCONTROL was already set
|
||||
// - Uses subshell (...) to isolate environment changes
|
||||
// This prevents temp file paths from appearing in shell history
|
||||
const command = `clear && ${cwdCommand} HISTFILE= HISTCONTROL=ignorespace bash -c 'source "${tempFile}" && rm -f "${tempFile}" && exec claude'\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)');
|
||||
terminal.pty.write(command);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) ==========');
|
||||
return;
|
||||
} else if (activeProfile.configDir) {
|
||||
terminal.pty.write(`${cwdCommand}CLAUDE_CONFIG_DIR="${activeProfile.configDir}" claude\r`);
|
||||
console.warn('[ClaudeIntegration] Using Claude profile:', activeProfile.name, 'config:', activeProfile.configDir);
|
||||
// Clear terminal and run command without adding to shell history:
|
||||
// Same history-disabling technique as temp file method above
|
||||
// SECURITY: Use escapeShellArg for configDir to prevent command injection
|
||||
// Set CLAUDE_CONFIG_DIR as env var before bash -c to avoid embedding user input in the command string
|
||||
const escapedConfigDir = escapeShellArg(activeProfile.configDir);
|
||||
const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} bash -c 'exec claude'\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)');
|
||||
terminal.pty.write(command);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) ==========');
|
||||
return;
|
||||
} else {
|
||||
debugLog('[ClaudeIntegration:invokeClaude] WARNING: No token or configDir available for non-default profile');
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProfile && !activeProfile.isDefault) {
|
||||
console.warn('[ClaudeIntegration] Using Claude profile:', activeProfile.name, '(from terminal environment)');
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name);
|
||||
}
|
||||
|
||||
terminal.pty.write(`${cwdCommand}claude\r`);
|
||||
const command = `${cwdCommand}claude\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command);
|
||||
terminal.pty.write(command);
|
||||
|
||||
if (activeProfile) {
|
||||
profileManager.markProfileUsed(activeProfile.id);
|
||||
@@ -220,6 +308,8 @@ export function invokeClaude(
|
||||
if (projectPath) {
|
||||
onSessionCapture(terminal.id, projectPath, startTime);
|
||||
}
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (default) ==========');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +324,8 @@ export function resumeClaude(
|
||||
|
||||
let command: string;
|
||||
if (sessionId) {
|
||||
command = `claude --resume "${sessionId}"`;
|
||||
// SECURITY: Escape sessionId to prevent command injection
|
||||
command = `claude --resume ${escapeShellArg(sessionId)}`;
|
||||
terminal.claudeSessionId = sessionId;
|
||||
} else {
|
||||
command = 'claude --continue';
|
||||
@@ -248,6 +339,103 @@ export function resumeClaude(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for waiting for Claude to exit
|
||||
*/
|
||||
interface WaitForExitConfig {
|
||||
/** Maximum time to wait for Claude to exit (ms) */
|
||||
timeout?: number;
|
||||
/** Interval between checks (ms) */
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of waiting for Claude to exit
|
||||
*/
|
||||
interface WaitForExitResult {
|
||||
/** Whether Claude exited successfully */
|
||||
success: boolean;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Whether the operation timed out */
|
||||
timedOut?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell prompt patterns that indicate Claude has exited and shell is ready
|
||||
* These patterns match common shell prompts across bash, zsh, fish, etc.
|
||||
*/
|
||||
const SHELL_PROMPT_PATTERNS = [
|
||||
/[$%#>❯]\s*$/m, // Common prompt endings: $, %, #, >, ❯
|
||||
/\w+@[\w.-]+[:\s]/, // user@hostname: format
|
||||
/^\s*\S+\s*[$%#>❯]\s*$/m, // hostname/path followed by prompt char
|
||||
/\(.*\)\s*[$%#>❯]\s*$/m, // (venv) or (branch) followed by prompt
|
||||
];
|
||||
|
||||
/**
|
||||
* Wait for Claude to exit by monitoring terminal output for shell prompt
|
||||
*
|
||||
* Instead of using fixed delays, this monitors the terminal's outputBuffer
|
||||
* for patterns indicating that Claude has exited and the shell prompt is visible.
|
||||
*/
|
||||
async function waitForClaudeExit(
|
||||
terminal: TerminalProcess,
|
||||
config: WaitForExitConfig = {}
|
||||
): Promise<WaitForExitResult> {
|
||||
const { timeout = 5000, pollInterval = 100 } = config;
|
||||
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Waiting for Claude to exit...');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Config:', { timeout, pollInterval });
|
||||
|
||||
// Capture current buffer length to detect new output
|
||||
const initialBufferLength = terminal.outputBuffer.length;
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkForPrompt = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Check for timeout
|
||||
if (elapsed >= timeout) {
|
||||
console.warn('[ClaudeIntegration:waitForClaudeExit] Timeout waiting for Claude to exit after', timeout, 'ms');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Timeout reached, Claude may not have exited cleanly');
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Timeout waiting for Claude to exit after ${timeout}ms`,
|
||||
timedOut: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get new output since we started waiting
|
||||
const newOutput = terminal.outputBuffer.slice(initialBufferLength);
|
||||
|
||||
// Check if we can see a shell prompt in the new output
|
||||
for (const pattern of SHELL_PROMPT_PATTERNS) {
|
||||
if (pattern.test(newOutput)) {
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Shell prompt detected after', elapsed, 'ms');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Matched pattern:', pattern.toString());
|
||||
resolve({ success: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if isClaudeMode was cleared (set by other handlers)
|
||||
if (!terminal.isClaudeMode) {
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] isClaudeMode flag cleared after', elapsed, 'ms');
|
||||
resolve({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
setTimeout(checkForPrompt, pollInterval);
|
||||
};
|
||||
|
||||
// Start checking
|
||||
checkForPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch terminal to a different Claude profile
|
||||
*/
|
||||
@@ -258,27 +446,95 @@ export async function switchClaudeProfile(
|
||||
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => void,
|
||||
clearRateLimitCallback: (terminalId: string) => void
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Always-on tracing
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Called for terminal:', terminal.id, '| profileId:', profileId);
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Terminal state: isClaudeMode=', terminal.isClaudeMode);
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] ========== SWITCH PROFILE START ==========');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal ID:', terminal.id);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Target profile ID:', profileId);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal state:', {
|
||||
isClaudeMode: terminal.isClaudeMode,
|
||||
currentProfileId: terminal.claudeProfileId,
|
||||
claudeSessionId: terminal.claudeSessionId,
|
||||
projectPath: terminal.projectPath,
|
||||
cwd: terminal.cwd
|
||||
});
|
||||
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = profileManager.getProfile(profileId);
|
||||
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Profile found:', profile?.name || 'NOT FOUND');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Target profile:', profile ? {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
hasOAuthToken: !!profile.oauthToken,
|
||||
isDefault: profile.isDefault
|
||||
} : 'NOT FOUND');
|
||||
|
||||
if (!profile) {
|
||||
console.error('[ClaudeIntegration:switchClaudeProfile] Profile not found, aborting');
|
||||
debugError('[ClaudeIntegration:switchClaudeProfile] Profile not found, aborting');
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
console.warn('[ClaudeIntegration] Switching to Claude profile:', profile.name);
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Switching to profile:', profile.name);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Switching to Claude profile:', profile.name);
|
||||
|
||||
if (terminal.isClaudeMode) {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Sending exit commands (Ctrl+C, /exit)');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal is in Claude mode, sending exit commands');
|
||||
|
||||
// Send Ctrl+C to interrupt any ongoing operation
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Sending Ctrl+C (\\x03)');
|
||||
terminal.pty.write('\x03');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Wait briefly for Ctrl+C to take effect before sending /exit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Send /exit command
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Sending /exit command');
|
||||
terminal.pty.write('/exit\r');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Wait for Claude to actually exit by monitoring for shell prompt
|
||||
const exitResult = await waitForClaudeExit(terminal, { timeout: 5000, pollInterval: 100 });
|
||||
|
||||
if (exitResult.timedOut) {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Timed out waiting for Claude to exit, proceeding with caution');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Exit timeout - terminal may be in inconsistent state');
|
||||
|
||||
// Even on timeout, we'll try to proceed but log the warning
|
||||
// The alternative would be to abort, but that could leave users stuck
|
||||
// If this becomes a problem, we could add retry logic or abort option
|
||||
} else if (!exitResult.success) {
|
||||
console.error('[ClaudeIntegration:switchClaudeProfile] Failed to exit Claude:', exitResult.error);
|
||||
debugError('[ClaudeIntegration:switchClaudeProfile] Exit failed:', exitResult.error);
|
||||
// Continue anyway - the /exit command was sent
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Claude exited successfully');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Claude exited, ready to switch profile');
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] NOT in Claude mode, skipping exit commands');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal NOT in Claude mode, skipping exit commands');
|
||||
}
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Clearing rate limit state for terminal');
|
||||
clearRateLimitCallback(terminal.id);
|
||||
|
||||
const projectPath = terminal.projectPath || terminal.cwd;
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with profile:', profileId, '| cwd:', projectPath);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with new profile:', {
|
||||
terminalId: terminal.id,
|
||||
projectPath,
|
||||
profileId
|
||||
});
|
||||
invokeClaudeCallback(terminal.id, projectPath, profileId);
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Setting active profile in profile manager');
|
||||
profileManager.setActiveProfile(profileId);
|
||||
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] COMPLETE');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] ========== SWITCH PROFILE COMPLETE ==========');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import * as net from 'net';
|
||||
import * as fs from 'fs';
|
||||
import * as pty from 'node-pty';
|
||||
import * as pty from '@lydell/node-pty';
|
||||
|
||||
const SOCKET_PATH =
|
||||
process.platform === 'win32'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Handles low-level PTY process creation and lifecycle
|
||||
*/
|
||||
|
||||
import * as pty from 'node-pty';
|
||||
import * as pty from '@lydell/node-pty';
|
||||
import * as os from 'os';
|
||||
import type { TerminalProcess, WindowGetter } from './types';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type * as pty from 'node-pty';
|
||||
import type * as pty from '@lydell/node-pty';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,12 @@ import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
|
||||
import { findPythonCommand, parsePythonCommand } from './python-detector';
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
|
||||
* Debug logging - only logs when DEBUG=true or in development mode
|
||||
*/
|
||||
const DEBUG = process.env.AUTO_CLAUDE_DEBUG === 'true' || process.env.AUTO_CLAUDE_DEBUG === '1';
|
||||
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
function debug(...args: unknown[]): void {
|
||||
if (DEBUG) {
|
||||
@@ -20,7 +21,8 @@ function debug(...args: unknown[]): void {
|
||||
* Service for generating task titles from descriptions using Claude AI
|
||||
*/
|
||||
export class TitleGenerator extends EventEmitter {
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
constructor() {
|
||||
@@ -129,7 +131,9 @@ export class TitleGenerator extends EventEmitter {
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
|
||||
cwd: autoBuildSource,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -11,12 +11,12 @@ import { TIMEOUTS } from './config';
|
||||
*/
|
||||
export function fetchJson<T>(url: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
}, (response) => {
|
||||
const headers = {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/vnd.github+json'
|
||||
};
|
||||
|
||||
const request = https.get(url, { headers }, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirectUrl = response.headers.location;
|
||||
@@ -27,7 +27,19 @@ export function fetchJson<T>(url: string): Promise<T> {
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
// Collect response body for error details (limit to 10KB)
|
||||
const maxErrorSize = 10 * 1024;
|
||||
let errorData = '';
|
||||
response.on('data', chunk => {
|
||||
if (errorData.length < maxErrorSize) {
|
||||
errorData += chunk.toString().slice(0, maxErrorSize - errorData.length);
|
||||
}
|
||||
});
|
||||
response.on('end', () => {
|
||||
const errorMsg = `HTTP ${response.statusCode}: ${errorData || response.statusMessage || 'No error details'}`;
|
||||
reject(new Error(errorMsg));
|
||||
});
|
||||
response.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,12 +74,15 @@ export function downloadFile(
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = createWriteStream(destPath);
|
||||
|
||||
const request = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/octet-stream'
|
||||
}
|
||||
}, (response) => {
|
||||
// GitHub API URLs need the GitHub Accept header to get a redirect to the actual file
|
||||
// Non-API URLs (CDN, direct downloads) use octet-stream
|
||||
const isGitHubApi = url.includes('api.github.com');
|
||||
const headers = {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': isGitHubApi ? 'application/vnd.github+json' : 'application/octet-stream'
|
||||
};
|
||||
|
||||
const request = https.get(url, { headers }, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
file.close();
|
||||
@@ -80,7 +95,19 @@ export function downloadFile(
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
// Collect response body for error details (limit to 10KB)
|
||||
const maxErrorSize = 10 * 1024;
|
||||
let errorData = '';
|
||||
response.on('data', chunk => {
|
||||
if (errorData.length < maxErrorSize) {
|
||||
errorData += chunk.toString().slice(0, maxErrorSize - errorData.length);
|
||||
}
|
||||
});
|
||||
response.on('end', () => {
|
||||
const errorMsg = `HTTP ${response.statusCode}: ${errorData || response.statusMessage || 'No error details'}`;
|
||||
reject(new Error(errorMsg));
|
||||
});
|
||||
response.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
import { GITHUB_CONFIG } from './config';
|
||||
import { fetchJson } from './http-client';
|
||||
import { getBundledVersion, parseVersionFromTag, compareVersions } from './version-manager';
|
||||
import { getEffectiveVersion, parseVersionFromTag, compareVersions } from './version-manager';
|
||||
import { GitHubRelease, AutoBuildUpdateCheck } from './types';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
// Cache for the latest release info (used by download)
|
||||
let cachedLatestRelease: GitHubRelease | null = null;
|
||||
@@ -35,7 +36,9 @@ export function clearCachedRelease(): void {
|
||||
* Check GitHub Releases for the latest version
|
||||
*/
|
||||
export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
const currentVersion = getBundledVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const currentVersion = getEffectiveVersion();
|
||||
debugLog('[UpdateCheck] Current effective version:', currentVersion);
|
||||
|
||||
try {
|
||||
// Fetch latest release from GitHub Releases API
|
||||
@@ -47,9 +50,11 @@ export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
|
||||
// Parse version from tag (e.g., "v1.2.0" -> "1.2.0")
|
||||
const latestVersion = parseVersionFromTag(release.tag_name);
|
||||
debugLog('[UpdateCheck] Latest version:', latestVersion);
|
||||
|
||||
// Compare versions
|
||||
const updateAvailable = compareVersions(latestVersion, currentVersion) > 0;
|
||||
debugLog('[UpdateCheck] Update available:', updateAvailable);
|
||||
|
||||
return {
|
||||
updateAvailable,
|
||||
@@ -61,6 +66,7 @@ export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
} catch (error) {
|
||||
// Clear cache on error
|
||||
clearCachedRelease();
|
||||
debugLog('[UpdateCheck] Error:', error instanceof Error ? error.message : error);
|
||||
|
||||
return {
|
||||
updateAvailable: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getUpdateCachePath, getUpdateTargetPath } from './path-resolver';
|
||||
import { extractTarball, copyDirectoryRecursive, preserveFiles, restoreFiles, cleanTargetDirectory } from './file-operations';
|
||||
import { getCachedRelease, setCachedRelease, clearCachedRelease } from './update-checker';
|
||||
import { GitHubRelease, AutoBuildUpdateResult, UpdateProgressCallback, UpdateMetadata } from './types';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Download and apply the latest auto-claude update from GitHub Releases
|
||||
@@ -25,6 +26,9 @@ export async function downloadAndApplyUpdate(
|
||||
): Promise<AutoBuildUpdateResult> {
|
||||
const cachePath = getUpdateCachePath();
|
||||
|
||||
debugLog('[Update] Starting update process...');
|
||||
debugLog('[Update] Cache path:', cachePath);
|
||||
|
||||
try {
|
||||
onProgress?.({
|
||||
stage: 'checking',
|
||||
@@ -34,19 +38,26 @@ export async function downloadAndApplyUpdate(
|
||||
// Ensure cache directory exists
|
||||
if (!existsSync(cachePath)) {
|
||||
mkdirSync(cachePath, { recursive: true });
|
||||
debugLog('[Update] Created cache directory');
|
||||
}
|
||||
|
||||
// Get release info (use cache or fetch fresh)
|
||||
let release = getCachedRelease();
|
||||
if (!release) {
|
||||
const releaseUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`;
|
||||
debugLog('[Update] Fetching release info from:', releaseUrl);
|
||||
release = await fetchJson<GitHubRelease>(releaseUrl);
|
||||
setCachedRelease(release);
|
||||
} else {
|
||||
debugLog('[Update] Using cached release info');
|
||||
}
|
||||
|
||||
// Use the release tarball URL
|
||||
const tarballUrl = release.tarball_url;
|
||||
// Use explicit tag reference URL to avoid HTTP 300 when branch/tag names collide
|
||||
// See: https://github.com/AndyMik90/Auto-Claude/issues/78
|
||||
const tarballUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/tarball/refs/tags/${release.tag_name}`;
|
||||
const releaseVersion = parseVersionFromTag(release.tag_name);
|
||||
debugLog('[Update] Release version:', releaseVersion);
|
||||
debugLog('[Update] Tarball URL:', tarballUrl);
|
||||
|
||||
const tarballPath = path.join(cachePath, 'auto-claude-update.tar.gz');
|
||||
const extractPath = path.join(cachePath, 'extracted');
|
||||
@@ -63,6 +74,8 @@ export async function downloadAndApplyUpdate(
|
||||
message: 'Downloading update...'
|
||||
});
|
||||
|
||||
debugLog('[Update] Starting download to:', tarballPath);
|
||||
|
||||
// Download the tarball
|
||||
await downloadFile(tarballUrl, tarballPath, (percent) => {
|
||||
onProgress?.({
|
||||
@@ -72,14 +85,20 @@ export async function downloadAndApplyUpdate(
|
||||
});
|
||||
});
|
||||
|
||||
debugLog('[Update] Download complete');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'extracting',
|
||||
message: 'Extracting update...'
|
||||
});
|
||||
|
||||
debugLog('[Update] Extracting to:', extractPath);
|
||||
|
||||
// Extract the tarball
|
||||
await extractTarball(tarballPath, extractPath);
|
||||
|
||||
debugLog('[Update] Extraction complete');
|
||||
|
||||
// Find the auto-claude folder in extracted content
|
||||
// GitHub tarballs have a root folder like "owner-repo-hash/"
|
||||
const extractedDirs = readdirSync(extractPath);
|
||||
@@ -96,6 +115,7 @@ export async function downloadAndApplyUpdate(
|
||||
|
||||
// Determine where to install the update
|
||||
const targetPath = getUpdateTargetPath();
|
||||
debugLog('[Update] Target install path:', targetPath);
|
||||
|
||||
// Backup existing source (if in dev mode)
|
||||
const backupPath = path.join(cachePath, 'backup');
|
||||
@@ -104,11 +124,14 @@ export async function downloadAndApplyUpdate(
|
||||
rmSync(backupPath, { recursive: true, force: true });
|
||||
}
|
||||
// Simple copy for backup
|
||||
debugLog('[Update] Creating backup at:', backupPath);
|
||||
copyDirectoryRecursive(targetPath, backupPath);
|
||||
}
|
||||
|
||||
// Apply the update
|
||||
debugLog('[Update] Applying update...');
|
||||
await applyUpdate(targetPath, autoBuildSource);
|
||||
debugLog('[Update] Update applied successfully');
|
||||
|
||||
// Write update metadata
|
||||
const metadata: UpdateMetadata = {
|
||||
@@ -132,14 +155,26 @@ export async function downloadAndApplyUpdate(
|
||||
message: `Updated to version ${releaseVersion}`
|
||||
});
|
||||
|
||||
debugLog('[Update] ============================================');
|
||||
debugLog('[Update] UPDATE SUCCESSFUL');
|
||||
debugLog('[Update] New version:', releaseVersion);
|
||||
debugLog('[Update] Target path:', targetPath);
|
||||
debugLog('[Update] ============================================');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version: releaseVersion
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Update failed';
|
||||
debugLog('[Update] ============================================');
|
||||
debugLog('[Update] UPDATE FAILED');
|
||||
debugLog('[Update] Error:', errorMessage);
|
||||
debugLog('[Update] ============================================');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'error',
|
||||
message: error instanceof Error ? error.message : 'Update failed'
|
||||
message: errorMessage
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,17 +3,85 @@
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UpdateMetadata } from './types';
|
||||
|
||||
/**
|
||||
* Get the current app/framework version
|
||||
* Get the current app/framework version from package.json
|
||||
*
|
||||
* Uses app.getVersion() (from package.json) as the single source of truth.
|
||||
* Both the Electron app and auto-claude framework share the same version.
|
||||
* Uses app.getVersion() (from package.json) as the base version.
|
||||
*/
|
||||
export function getBundledVersion(): string {
|
||||
return app.getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective version - accounts for source updates
|
||||
*
|
||||
* Returns the updated source version if an update has been applied,
|
||||
* otherwise returns the bundled version.
|
||||
*/
|
||||
export function getEffectiveVersion(): string {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
// Build list of paths to check for update metadata
|
||||
const metadataPaths: string[] = [];
|
||||
|
||||
if (app.isPackaged) {
|
||||
// Production: check userData override path
|
||||
metadataPaths.push(
|
||||
path.join(app.getPath('userData'), 'auto-claude-source', '.update-metadata.json')
|
||||
);
|
||||
} else {
|
||||
// Development: check the actual source paths where updates are written
|
||||
const possibleSourcePaths = [
|
||||
path.join(app.getAppPath(), '..', 'auto-claude'),
|
||||
path.join(app.getAppPath(), '..', '..', 'auto-claude'),
|
||||
path.join(process.cwd(), 'auto-claude'),
|
||||
path.join(process.cwd(), '..', 'auto-claude')
|
||||
];
|
||||
|
||||
for (const sourcePath of possibleSourcePaths) {
|
||||
metadataPaths.push(path.join(sourcePath, '.update-metadata.json'));
|
||||
}
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.log('[Version] Checking metadata paths:', metadataPaths);
|
||||
}
|
||||
|
||||
// Check each path for metadata
|
||||
for (const metadataPath of metadataPaths) {
|
||||
const exists = existsSync(metadataPath);
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Checking ${metadataPath}: ${exists ? 'EXISTS' : 'not found'}`);
|
||||
}
|
||||
if (exists) {
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as UpdateMetadata;
|
||||
if (metadata.version) {
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Found metadata version: ${metadata.version}`);
|
||||
}
|
||||
return metadata.version;
|
||||
}
|
||||
} catch (e) {
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Error reading metadata: ${e}`);
|
||||
}
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bundledVersion = app.getVersion();
|
||||
if (isDebug) {
|
||||
console.log(`[Version] No metadata found, using bundled version: ${bundledVersion}`);
|
||||
}
|
||||
return bundledVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version from GitHub release tag
|
||||
* Handles tags like "v1.2.0", "1.2.0", "v1.2.0-beta"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SettingsAPI, createSettingsAPI } from './settings-api';
|
||||
import { FileAPI, createFileAPI } from './file-api';
|
||||
import { AgentAPI, createAgentAPI } from './agent-api';
|
||||
import { IdeationAPI, createIdeationAPI } from './modules/ideation-api';
|
||||
import { InsightsAPI, createInsightsAPI } from './modules/insights-api';
|
||||
import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api';
|
||||
|
||||
export interface ElectronAPI extends
|
||||
@@ -15,6 +16,7 @@ export interface ElectronAPI extends
|
||||
FileAPI,
|
||||
AgentAPI,
|
||||
IdeationAPI,
|
||||
InsightsAPI,
|
||||
AppUpdateAPI {}
|
||||
|
||||
export const createElectronAPI = (): ElectronAPI => ({
|
||||
@@ -25,6 +27,7 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createFileAPI(),
|
||||
...createAgentAPI(),
|
||||
...createIdeationAPI(),
|
||||
...createInsightsAPI(),
|
||||
...createAppUpdateAPI()
|
||||
});
|
||||
|
||||
@@ -37,6 +40,7 @@ export {
|
||||
createFileAPI,
|
||||
createAgentAPI,
|
||||
createIdeationAPI,
|
||||
createInsightsAPI,
|
||||
createAppUpdateAPI
|
||||
};
|
||||
|
||||
@@ -48,5 +52,6 @@ export type {
|
||||
FileAPI,
|
||||
AgentAPI,
|
||||
IdeationAPI,
|
||||
InsightsAPI,
|
||||
AppUpdateAPI
|
||||
};
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface GitHubAPI {
|
||||
getGitHubRepositories: (projectId: string) => Promise<IPCResult<GitHubRepository[]>>;
|
||||
getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise<IPCResult<GitHubIssue[]>>;
|
||||
getGitHubIssue: (projectId: string, issueNumber: number) => Promise<IPCResult<GitHubIssue>>;
|
||||
getIssueComments: (projectId: string, issueNumber: number) => Promise<IPCResult<any[]>>;
|
||||
checkGitHubConnection: (projectId: string) => Promise<IPCResult<GitHubSyncStatus>>;
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number) => void;
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]) => void;
|
||||
importGitHubIssues: (projectId: string, issueNumbers: number[]) => Promise<IPCResult<GitHubImportResult>>;
|
||||
createGitHubRelease: (
|
||||
projectId: string,
|
||||
@@ -40,6 +41,10 @@ export interface GitHubAPI {
|
||||
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
|
||||
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
|
||||
|
||||
// Repository detection
|
||||
detectGitHubRepo: (projectPath: string) => Promise<IPCResult<string>>;
|
||||
getGitHubBranches: (repo: string, token: string) => Promise<IPCResult<string[]>>;
|
||||
|
||||
// Event Listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
callback: (projectId: string, status: GitHubInvestigationStatus) => void
|
||||
@@ -66,11 +71,14 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
getGitHubIssue: (projectId: string, issueNumber: number): Promise<IPCResult<GitHubIssue>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUE, projectId, issueNumber),
|
||||
|
||||
getIssueComments: (projectId: string, issueNumber: number): Promise<IPCResult<any[]>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUE_COMMENTS, projectId, issueNumber),
|
||||
|
||||
checkGitHubConnection: (projectId: string): Promise<IPCResult<GitHubSyncStatus>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_CHECK_CONNECTION, projectId),
|
||||
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number): void =>
|
||||
sendIpc(IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, projectId, issueNumber),
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]): void =>
|
||||
sendIpc(IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, projectId, issueNumber, selectedCommentIds),
|
||||
|
||||
importGitHubIssues: (projectId: string, issueNumbers: number[]): Promise<IPCResult<GitHubImportResult>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_IMPORT_ISSUES, projectId, issueNumbers),
|
||||
@@ -105,6 +113,13 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
listGitHubUserRepos: (): Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_LIST_USER_REPOS),
|
||||
|
||||
// Repository detection
|
||||
detectGitHubRepo: (projectPath: string): Promise<IPCResult<string>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_DETECT_REPO, projectPath),
|
||||
|
||||
getGitHubBranches: (repo: string, token: string): Promise<IPCResult<string[]>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_GET_BRANCHES, repo, token),
|
||||
|
||||
// Event Listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
callback: (projectId: string, status: GitHubInvestigationStatus) => void
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
InsightsSessionSummary,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
InsightsModelConfig,
|
||||
Task,
|
||||
TaskMetadata,
|
||||
IPCResult
|
||||
@@ -16,7 +17,7 @@ import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc
|
||||
export interface InsightsAPI {
|
||||
// Operations
|
||||
getInsightsSession: (projectId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
sendInsightsMessage: (projectId: string, message: string) => void;
|
||||
sendInsightsMessage: (projectId: string, message: string, modelConfig?: InsightsModelConfig) => void;
|
||||
clearInsightsSession: (projectId: string) => Promise<IPCResult>;
|
||||
createTaskFromInsights: (
|
||||
projectId: string,
|
||||
@@ -29,6 +30,7 @@ export interface InsightsAPI {
|
||||
switchInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
deleteInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult>;
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise<IPCResult>;
|
||||
updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig) => Promise<IPCResult>;
|
||||
|
||||
// Event Listeners
|
||||
onInsightsStreamChunk: (
|
||||
@@ -50,8 +52,8 @@ export const createInsightsAPI = (): InsightsAPI => ({
|
||||
getInsightsSession: (projectId: string): Promise<IPCResult<InsightsSession | null>> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_GET_SESSION, projectId),
|
||||
|
||||
sendInsightsMessage: (projectId: string, message: string): void =>
|
||||
sendIpc(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message),
|
||||
sendInsightsMessage: (projectId: string, message: string, modelConfig?: InsightsModelConfig): void =>
|
||||
sendIpc(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message, modelConfig),
|
||||
|
||||
clearInsightsSession: (projectId: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, projectId),
|
||||
@@ -79,6 +81,9 @@ export const createInsightsAPI = (): InsightsAPI => ({
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_RENAME_SESSION, projectId, sessionId, newTitle),
|
||||
|
||||
updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_UPDATE_MODEL_CONFIG, projectId, sessionId, modelConfig),
|
||||
|
||||
// Event Listeners
|
||||
onInsightsStreamChunk: (
|
||||
callback: (projectId: string, chunk: InsightsStreamChunk) => void
|
||||
|
||||
@@ -14,9 +14,11 @@ import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc
|
||||
export interface RoadmapAPI {
|
||||
// Operations
|
||||
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
|
||||
getRoadmapStatus: (projectId: string) => Promise<IPCResult<{ isRunning: boolean }>>;
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => void;
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => void;
|
||||
stopRoadmap: (projectId: string) => Promise<IPCResult>;
|
||||
updateFeatureStatus: (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
@@ -37,6 +39,9 @@ export interface RoadmapAPI {
|
||||
onRoadmapError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
) => IpcListenerCleanup;
|
||||
onRoadmapStopped: (
|
||||
callback: (projectId: string) => void
|
||||
) => IpcListenerCleanup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,14 +52,20 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
|
||||
getRoadmap: (projectId: string): Promise<IPCResult<Roadmap | null>> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_GET, projectId),
|
||||
|
||||
getRoadmapStatus: (projectId: string): Promise<IPCResult<{ isRunning: boolean }>> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_GET_STATUS, projectId),
|
||||
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_SAVE, projectId, roadmap),
|
||||
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_GENERATE, projectId, enableCompetitorAnalysis),
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_GENERATE, projectId, enableCompetitorAnalysis, refreshCompetitorAnalysis),
|
||||
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_REFRESH, projectId, enableCompetitorAnalysis),
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_REFRESH, projectId, enableCompetitorAnalysis, refreshCompetitorAnalysis),
|
||||
|
||||
stopRoadmap: (projectId: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_STOP, projectId),
|
||||
|
||||
updateFeatureStatus: (
|
||||
projectId: string,
|
||||
@@ -83,5 +94,10 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
|
||||
onRoadmapError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.ROADMAP_ERROR, callback)
|
||||
createIpcListener(IPC_CHANNELS.ROADMAP_ERROR, callback),
|
||||
|
||||
onRoadmapStopped: (
|
||||
callback: (projectId: string) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.ROADMAP_STOPPED, callback)
|
||||
});
|
||||
|
||||
@@ -6,3 +6,6 @@ const electronAPI = createElectronAPI();
|
||||
|
||||
// Expose to renderer via contextBridge
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
|
||||
// Expose debug flag for debug logging
|
||||
contextBridge.exposeInMainWorld('DEBUG', process.env.DEBUG === 'true');
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from './components/ui/tooltip';
|
||||
import { Sidebar, type SidebarView } from './components/Sidebar';
|
||||
import { KanbanBoard } from './components/KanbanBoard';
|
||||
import { TaskDetailPanel } from './components/TaskDetailPanel';
|
||||
import { TaskDetailModal } from './components/task-detail/TaskDetailModal';
|
||||
import { TaskCreationWizard } from './components/TaskCreationWizard';
|
||||
import { AppSettingsDialog, type AppSection } from './components/settings/AppSettings';
|
||||
import type { ProjectSettingsSection } from './components/settings/ProjectSettingsContent';
|
||||
@@ -29,7 +29,6 @@ import { Insights } from './components/Insights';
|
||||
import { GitHubIssues } from './components/GitHubIssues';
|
||||
import { Changelog } from './components/Changelog';
|
||||
import { Worktrees } from './components/Worktrees';
|
||||
import { AgentProfiles } from './components/AgentProfiles';
|
||||
import { WelcomeScreen } from './components/WelcomeScreen';
|
||||
import { RateLimitModal } from './components/RateLimitModal';
|
||||
import { SDKRateLimitModal } from './components/SDKRateLimitModal';
|
||||
@@ -37,12 +36,14 @@ import { OnboardingWizard } from './components/onboarding';
|
||||
import { AppUpdateNotification } from './components/AppUpdateNotification';
|
||||
import { UsageIndicator } from './components/UsageIndicator';
|
||||
import { ProactiveSwapListener } from './components/ProactiveSwapListener';
|
||||
import { GitHubSetupModal } from './components/GitHubSetupModal';
|
||||
import { useProjectStore, loadProjects, addProject, initializeProject } from './stores/project-store';
|
||||
import { useTaskStore, loadTasks } from './stores/task-store';
|
||||
import { useSettingsStore, loadSettings } from './stores/settings-store';
|
||||
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
|
||||
import { useIpcListeners } from './hooks/useIpc';
|
||||
import type { Task, Project } from '../shared/types';
|
||||
import { COLOR_THEMES } from '../shared/constants';
|
||||
import type { Task, Project, ColorTheme } from '../shared/types';
|
||||
|
||||
export function App() {
|
||||
// Load IPC listeners for real-time updates
|
||||
@@ -68,8 +69,14 @@ export function App() {
|
||||
const [showInitDialog, setShowInitDialog] = useState(false);
|
||||
const [pendingProject, setPendingProject] = useState<Project | null>(null);
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
const [initSuccess, setInitSuccess] = useState(false);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const [skippedInitProjectId, setSkippedInitProjectId] = useState<string | null>(null);
|
||||
|
||||
// GitHub setup state (shown after Auto Claude init)
|
||||
const [showGitHubSetup, setShowGitHubSetup] = useState(false);
|
||||
const [gitHubSetupProject, setGitHubSetupProject] = useState<Project | null>(null);
|
||||
|
||||
// Get selected project
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
|
||||
@@ -130,12 +137,17 @@ export function App() {
|
||||
|
||||
// Check if selected project needs initialization (e.g., .auto-claude folder was deleted)
|
||||
useEffect(() => {
|
||||
// Don't show dialog while initialization is in progress
|
||||
if (isInitializing) return;
|
||||
|
||||
if (selectedProject && !selectedProject.autoBuildPath && skippedInitProjectId !== selectedProject.id) {
|
||||
// Project exists but isn't initialized - show init dialog
|
||||
setPendingProject(selectedProject);
|
||||
setInitError(null); // Clear any previous errors
|
||||
setInitSuccess(false); // Reset success flag
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
}, [selectedProject, skippedInitProjectId]);
|
||||
}, [selectedProject, skippedInitProjectId, isInitializing]);
|
||||
|
||||
// Load tasks when project changes
|
||||
useEffect(() => {
|
||||
@@ -165,21 +177,38 @@ export function App() {
|
||||
|
||||
// Apply theme on load
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
const applyTheme = () => {
|
||||
// Apply light/dark mode
|
||||
if (settings.theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
root.classList.add('dark');
|
||||
} else if (settings.theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
// System preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Apply color theme via data-theme attribute
|
||||
// Validate colorTheme against known themes, fallback to 'default' if invalid
|
||||
const validThemeIds = COLOR_THEMES.map((t) => t.id);
|
||||
const rawColorTheme = settings.colorTheme ?? 'default';
|
||||
const colorTheme: ColorTheme = validThemeIds.includes(rawColorTheme as ColorTheme)
|
||||
? (rawColorTheme as ColorTheme)
|
||||
: 'default';
|
||||
|
||||
if (colorTheme === 'default') {
|
||||
root.removeAttribute('data-theme');
|
||||
} else {
|
||||
root.setAttribute('data-theme', colorTheme);
|
||||
}
|
||||
|
||||
applyTheme();
|
||||
|
||||
// Listen for system theme changes
|
||||
@@ -194,7 +223,7 @@ export function App() {
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [settings.theme]);
|
||||
}, [settings.theme, settings.colorTheme]);
|
||||
|
||||
// Update selected task when tasks change (for real-time updates)
|
||||
useEffect(() => {
|
||||
@@ -224,6 +253,8 @@ export function App() {
|
||||
if (project && !project.autoBuildPath) {
|
||||
// Project doesn't have Auto Claude initialized, show init dialog
|
||||
setPendingProject(project);
|
||||
setInitError(null); // Clear any previous errors
|
||||
setInitSuccess(false); // Reset success flag
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
}
|
||||
@@ -235,31 +266,107 @@ export function App() {
|
||||
const handleInitialize = async () => {
|
||||
if (!pendingProject) return;
|
||||
|
||||
const projectId = pendingProject.id;
|
||||
console.log('[InitDialog] Starting initialization for project:', projectId);
|
||||
setIsInitializing(true);
|
||||
setInitSuccess(false);
|
||||
setInitError(null); // Clear any previous errors
|
||||
try {
|
||||
const result = await initializeProject(pendingProject.id);
|
||||
const result = await initializeProject(projectId);
|
||||
console.log('[InitDialog] Initialization result:', result);
|
||||
|
||||
if (result?.success) {
|
||||
console.log('[InitDialog] Initialization successful, closing dialog');
|
||||
// Get the updated project from store
|
||||
const updatedProject = useProjectStore.getState().projects.find(p => p.id === projectId);
|
||||
console.log('[InitDialog] Updated project:', updatedProject);
|
||||
|
||||
// Mark as successful to prevent onOpenChange from treating this as a skip
|
||||
setInitSuccess(true);
|
||||
setIsInitializing(false);
|
||||
|
||||
// Now close the dialog
|
||||
setShowInitDialog(false);
|
||||
setPendingProject(null);
|
||||
|
||||
// Show GitHub setup modal
|
||||
if (updatedProject) {
|
||||
setGitHubSetupProject(updatedProject);
|
||||
setShowGitHubSetup(true);
|
||||
}
|
||||
} else {
|
||||
// Initialization failed - show error but keep dialog open
|
||||
console.log('[InitDialog] Initialization failed, showing error');
|
||||
const errorMessage = result?.error || 'Failed to initialize Auto Claude. Please try again.';
|
||||
setInitError(errorMessage);
|
||||
setIsInitializing(false);
|
||||
}
|
||||
} finally {
|
||||
} catch (error) {
|
||||
// Unexpected error occurred
|
||||
console.error('[InitDialog] Unexpected error during initialization:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
|
||||
setInitError(errorMessage);
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubSetupComplete = async (settings: {
|
||||
githubToken: string;
|
||||
githubRepo: string;
|
||||
mainBranch: string;
|
||||
}) => {
|
||||
if (!gitHubSetupProject) return;
|
||||
|
||||
try {
|
||||
// NOTE: settings.githubToken is a GitHub access token (from gh CLI),
|
||||
// NOT a Claude Code OAuth token. They are different things:
|
||||
// - GitHub token: for GitHub API access (repo operations)
|
||||
// - Claude token: for Claude AI access (run.py, roadmap, etc.)
|
||||
// The user needs to separately authenticate with Claude using 'claude setup-token'
|
||||
|
||||
// Update project env config with GitHub settings
|
||||
await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, {
|
||||
githubEnabled: true,
|
||||
githubToken: settings.githubToken, // GitHub token for repo access
|
||||
githubRepo: settings.githubRepo
|
||||
});
|
||||
|
||||
// Update project settings with mainBranch
|
||||
await window.electronAPI.updateProjectSettings(gitHubSetupProject.id, {
|
||||
mainBranch: settings.mainBranch
|
||||
});
|
||||
|
||||
// Refresh projects to get updated data
|
||||
await loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to save GitHub settings:', error);
|
||||
}
|
||||
|
||||
setShowGitHubSetup(false);
|
||||
setGitHubSetupProject(null);
|
||||
};
|
||||
|
||||
const handleGitHubSetupSkip = () => {
|
||||
setShowGitHubSetup(false);
|
||||
setGitHubSetupProject(null);
|
||||
};
|
||||
|
||||
const handleSkipInit = () => {
|
||||
console.log('[InitDialog] User skipped initialization');
|
||||
if (pendingProject) {
|
||||
setSkippedInitProjectId(pendingProject.id);
|
||||
}
|
||||
setShowInitDialog(false);
|
||||
setPendingProject(null);
|
||||
setInitError(null); // Clear any error when skipping
|
||||
setInitSuccess(false); // Reset success flag
|
||||
};
|
||||
|
||||
const handleGoToTask = (taskId: string) => {
|
||||
// Switch to kanban view
|
||||
setActiveView('kanban');
|
||||
// Find and select the task
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
// Find and select the task (match by id or specId)
|
||||
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
if (task) {
|
||||
setSelectedTask(task);
|
||||
}
|
||||
@@ -340,10 +447,13 @@ export function App() {
|
||||
<Insights projectId={selectedProjectId} />
|
||||
)}
|
||||
{activeView === 'github-issues' && selectedProjectId && (
|
||||
<GitHubIssues onOpenSettings={() => {
|
||||
setSettingsInitialProjectSection('github');
|
||||
setIsSettingsDialogOpen(true);
|
||||
}} />
|
||||
<GitHubIssues
|
||||
onOpenSettings={() => {
|
||||
setSettingsInitialProjectSection('github');
|
||||
setIsSettingsDialogOpen(true);
|
||||
}}
|
||||
onNavigateToTask={handleGoToTask}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'changelog' && selectedProjectId && (
|
||||
<Changelog />
|
||||
@@ -351,9 +461,6 @@ export function App() {
|
||||
{activeView === 'worktrees' && selectedProjectId && (
|
||||
<Worktrees projectId={selectedProjectId} />
|
||||
)}
|
||||
{activeView === 'agent-profiles' && (
|
||||
<AgentProfiles />
|
||||
)}
|
||||
{activeView === 'agent-tools' && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
@@ -376,10 +483,12 @@ export function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Task detail panel */}
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel task={selectedTask} onClose={handleCloseTaskDetail} />
|
||||
)}
|
||||
{/* Task detail modal */}
|
||||
<TaskDetailModal
|
||||
open={!!selectedTask}
|
||||
task={selectedTask}
|
||||
onOpenChange={(open) => !open && handleCloseTaskDetail()}
|
||||
/>
|
||||
|
||||
{/* Dialogs */}
|
||||
{selectedProjectId && (
|
||||
@@ -414,7 +523,10 @@ export function App() {
|
||||
|
||||
{/* Initialize Auto Claude Dialog */}
|
||||
<Dialog open={showInitDialog} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
console.log('[InitDialog] onOpenChange called', { open, pendingProject: !!pendingProject, isInitializing, initSuccess });
|
||||
// Only trigger skip if user manually closed the dialog
|
||||
// Don't trigger if: successful init, no pending project, or currently initializing
|
||||
if (!open && pendingProject && !isInitializing && !initSuccess) {
|
||||
handleSkipInit();
|
||||
}
|
||||
}}>
|
||||
@@ -450,6 +562,19 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{initError && (
|
||||
<div className="mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Initialization Failed</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{initError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleSkipInit} disabled={isInitializing}>
|
||||
@@ -475,6 +600,17 @@ export function App() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* GitHub Setup Modal - shows after Auto Claude init to configure GitHub */}
|
||||
{gitHubSetupProject && (
|
||||
<GitHubSetupModal
|
||||
open={showGitHubSetup}
|
||||
onOpenChange={setShowGitHubSetup}
|
||||
project={gitHubSetupProject}
|
||||
onComplete={handleGitHubSetupComplete}
|
||||
onSkip={handleGitHubSetupSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rate Limit Modal - shows when Claude Code hits usage limits (terminal) */}
|
||||
<RateLimitModal />
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ function createTestFeature(overrides: Partial<RoadmapFeature> = {}): RoadmapFeat
|
||||
impact: 'medium',
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: ['Test criteria'],
|
||||
userStories: ['As a user, I want to test'],
|
||||
...overrides
|
||||
@@ -309,7 +309,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'high' as const,
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: ['Criteria 1'],
|
||||
userStories: ['User story 1']
|
||||
};
|
||||
@@ -336,7 +336,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'medium' as const,
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
};
|
||||
@@ -393,7 +393,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'high' as const,
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
});
|
||||
@@ -414,7 +414,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'high' as const,
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
});
|
||||
@@ -438,7 +438,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'medium' as const,
|
||||
phaseId: 'phase-3',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
});
|
||||
@@ -489,7 +489,7 @@ describe('Roadmap Store', () => {
|
||||
|
||||
describe('updateFeatureStatus', () => {
|
||||
it('should update feature status by id', () => {
|
||||
const features = [createTestFeature({ id: 'feature-1', status: 'idea' })];
|
||||
const features = [createTestFeature({ id: 'feature-1', status: 'under_review' })];
|
||||
const roadmap = createTestRoadmap({ features });
|
||||
|
||||
useRoadmapStore.setState({ roadmap });
|
||||
@@ -502,8 +502,8 @@ describe('Roadmap Store', () => {
|
||||
});
|
||||
|
||||
describe('updateFeatureLinkedSpec', () => {
|
||||
it('should update linked spec and set status to planned', () => {
|
||||
const features = [createTestFeature({ id: 'feature-1', status: 'idea' })];
|
||||
it('should update linked spec and set status to in_progress', () => {
|
||||
const features = [createTestFeature({ id: 'feature-1', status: 'under_review' })];
|
||||
const roadmap = createTestRoadmap({ features });
|
||||
|
||||
useRoadmapStore.setState({ roadmap });
|
||||
@@ -512,7 +512,7 @@ describe('Roadmap Store', () => {
|
||||
|
||||
const state = useRoadmapStore.getState();
|
||||
expect(state.roadmap?.features[0].linkedSpecId).toBe('spec-abc');
|
||||
expect(state.roadmap?.features[0].status).toBe('planned');
|
||||
expect(state.roadmap?.features[0].status).toBe('in_progress');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -596,9 +596,9 @@ describe('Roadmap Store', () => {
|
||||
it('should return correct stats', () => {
|
||||
const roadmap = createTestRoadmap({
|
||||
features: [
|
||||
createTestFeature({ priority: 'must', status: 'idea', complexity: 'high' }),
|
||||
createTestFeature({ priority: 'must', status: 'under_review', complexity: 'high' }),
|
||||
createTestFeature({ priority: 'must', status: 'planned', complexity: 'medium' }),
|
||||
createTestFeature({ priority: 'should', status: 'idea', complexity: 'low' })
|
||||
createTestFeature({ priority: 'should', status: 'under_review', complexity: 'low' })
|
||||
]
|
||||
});
|
||||
|
||||
@@ -607,7 +607,7 @@ describe('Roadmap Store', () => {
|
||||
expect(stats.total).toBe(3);
|
||||
expect(stats.byPriority['must']).toBe(2);
|
||||
expect(stats.byPriority['should']).toBe(1);
|
||||
expect(stats.byStatus['idea']).toBe(2);
|
||||
expect(stats.byStatus['under_review']).toBe(2);
|
||||
expect(stats.byStatus['planned']).toBe(1);
|
||||
expect(stats.byComplexity['high']).toBe(1);
|
||||
expect(stats.byComplexity['medium']).toBe(1);
|
||||
|
||||
@@ -48,7 +48,8 @@ import {
|
||||
import type {
|
||||
RoadmapPhase,
|
||||
RoadmapFeaturePriority,
|
||||
RoadmapFeatureStatus
|
||||
RoadmapFeatureStatus,
|
||||
FeatureSource
|
||||
} from '../../shared/types';
|
||||
|
||||
/**
|
||||
@@ -147,9 +148,10 @@ export function AddFeatureDialog({
|
||||
impact,
|
||||
phaseId,
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
userStories: [],
|
||||
source: { provider: 'internal' }
|
||||
});
|
||||
|
||||
// Persist to file via IPC
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* AgentProfileSelector - Reusable component for selecting agent profile in forms
|
||||
*
|
||||
* Provides a dropdown for quick profile selection (Auto, Complex, Balanced, Quick)
|
||||
* with an inline "Custom" option that reveals model and thinking level selects.
|
||||
* The "Auto" profile shows per-phase model configuration.
|
||||
*
|
||||
* Used in TaskCreationWizard and TaskEditDialog.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp, Pencil } from 'lucide-react';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import {
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
AVAILABLE_MODELS,
|
||||
THINKING_LEVELS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING
|
||||
} from '../../shared/constants';
|
||||
import type { ModelType, ThinkingLevel } from '../../shared/types';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface AgentProfileSelectorProps {
|
||||
/** Currently selected profile ID ('auto', 'complex', 'balanced', 'quick', or 'custom') */
|
||||
profileId: string;
|
||||
/** Current model value (fallback for non-auto profiles) */
|
||||
model: ModelType | '';
|
||||
/** Current thinking level value (fallback for non-auto profiles) */
|
||||
thinkingLevel: ThinkingLevel | '';
|
||||
/** Phase model configuration (for auto profile) */
|
||||
phaseModels?: PhaseModelConfig;
|
||||
/** Phase thinking configuration (for auto profile) */
|
||||
phaseThinking?: PhaseThinkingConfig;
|
||||
/** Called when profile selection changes */
|
||||
onProfileChange: (profileId: string, model: ModelType, thinkingLevel: ThinkingLevel) => void;
|
||||
/** Called when model changes (in custom mode) */
|
||||
onModelChange: (model: ModelType) => void;
|
||||
/** Called when thinking level changes (in custom mode) */
|
||||
onThinkingLevelChange: (level: ThinkingLevel) => void;
|
||||
/** Called when phase models change (in auto mode) */
|
||||
onPhaseModelsChange?: (phaseModels: PhaseModelConfig) => void;
|
||||
/** Called when phase thinking changes (in auto mode) */
|
||||
onPhaseThinkingChange?: (phaseThinking: PhaseThinkingConfig) => void;
|
||||
/** Whether the selector is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Brain,
|
||||
Scale,
|
||||
Zap,
|
||||
Sparkles
|
||||
};
|
||||
|
||||
const PHASE_LABELS: Record<keyof PhaseModelConfig, { label: string; description: string }> = {
|
||||
spec: { label: 'Spec Creation', description: 'Discovery, requirements, context gathering' },
|
||||
planning: { label: 'Planning', description: 'Implementation planning and architecture' },
|
||||
coding: { label: 'Coding', description: 'Actual code implementation' },
|
||||
qa: { label: 'QA Review', description: 'Quality assurance and validation' }
|
||||
};
|
||||
|
||||
export function AgentProfileSelector({
|
||||
profileId,
|
||||
model,
|
||||
thinkingLevel,
|
||||
phaseModels,
|
||||
phaseThinking,
|
||||
onProfileChange,
|
||||
onModelChange,
|
||||
onThinkingLevelChange,
|
||||
onPhaseModelsChange,
|
||||
onPhaseThinkingChange,
|
||||
disabled
|
||||
}: AgentProfileSelectorProps) {
|
||||
const [showPhaseDetails, setShowPhaseDetails] = useState(false);
|
||||
|
||||
const isCustom = profileId === 'custom';
|
||||
const isAuto = profileId === 'auto';
|
||||
|
||||
// Use provided phase configs or defaults
|
||||
const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS;
|
||||
const currentPhaseThinking = phaseThinking || DEFAULT_PHASE_THINKING;
|
||||
|
||||
const handleProfileSelect = (selectedId: string) => {
|
||||
if (selectedId === 'custom') {
|
||||
// Keep current model/thinking level, just mark as custom
|
||||
onProfileChange('custom', model as ModelType || 'sonnet', thinkingLevel as ThinkingLevel || 'medium');
|
||||
} else if (selectedId === 'auto') {
|
||||
// Auto profile - set defaults
|
||||
const autoProfile = DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto');
|
||||
if (autoProfile) {
|
||||
onProfileChange('auto', autoProfile.model, autoProfile.thinkingLevel);
|
||||
// Initialize phase configs with defaults if callback provided
|
||||
if (onPhaseModelsChange && autoProfile.phaseModels) {
|
||||
onPhaseModelsChange(autoProfile.phaseModels);
|
||||
}
|
||||
if (onPhaseThinkingChange && autoProfile.phaseThinking) {
|
||||
onPhaseThinkingChange(autoProfile.phaseThinking);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedId);
|
||||
if (profile) {
|
||||
onProfileChange(profile.id, profile.model, profile.thinkingLevel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseModelChange = (phase: keyof PhaseModelConfig, value: ModelType) => {
|
||||
if (onPhaseModelsChange) {
|
||||
onPhaseModelsChange({
|
||||
...currentPhaseModels,
|
||||
[phase]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseThinkingChange = (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => {
|
||||
if (onPhaseThinkingChange) {
|
||||
onPhaseThinkingChange({
|
||||
...currentPhaseThinking,
|
||||
[phase]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get profile display info
|
||||
const getProfileDisplay = () => {
|
||||
if (isCustom) {
|
||||
return {
|
||||
icon: Sliders,
|
||||
label: 'Custom Configuration',
|
||||
description: 'Choose model & thinking level'
|
||||
};
|
||||
}
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId);
|
||||
if (profile) {
|
||||
return {
|
||||
icon: iconMap[profile.icon || 'Scale'] || Scale,
|
||||
label: profile.name,
|
||||
description: profile.description
|
||||
};
|
||||
}
|
||||
// Default to balanced
|
||||
return {
|
||||
icon: Scale,
|
||||
label: 'Balanced',
|
||||
description: 'Good balance of speed and quality'
|
||||
};
|
||||
};
|
||||
|
||||
const display = getProfileDisplay();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Agent Profile Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-profile" className="text-sm font-medium text-foreground">
|
||||
Agent Profile
|
||||
</Label>
|
||||
<Select
|
||||
value={profileId}
|
||||
onValueChange={handleProfileSelect}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="agent-profile" className="h-10">
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<display.icon className="h-4 w-4" />
|
||||
<span>{display.label}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_AGENT_PROFILES.map((profile) => {
|
||||
const ProfileIcon = iconMap[profile.icon || 'Scale'] || Scale;
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === profile.model)?.label;
|
||||
return (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileIcon className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{profile.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{profile.isAutoProfile
|
||||
? '(per-phase optimization)'
|
||||
: `(${modelLabel} + ${profile.thinkingLevel})`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">Custom</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
(Choose model & thinking level)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{display.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Profile - Phase Configuration */}
|
||||
{isAuto && (
|
||||
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
|
||||
{/* Clickable Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPhaseDetails(!showPhaseDetails)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between p-4 text-left',
|
||||
'hover:bg-muted/50 transition-colors',
|
||||
!disabled && 'cursor-pointer'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-foreground">Phase Configuration</span>
|
||||
{!showPhaseDetails && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Click to customize</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showPhaseDetails ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Compact summary when collapsed */}
|
||||
{!showPhaseDetails && (
|
||||
<div className="px-4 pb-4 -mt-1">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => {
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentPhaseModels[phase])?.label?.replace('Claude ', '') || currentPhaseModels[phase];
|
||||
return (
|
||||
<div key={phase} className="flex items-center justify-between rounded bg-background/50 px-2 py-1">
|
||||
<span className="text-muted-foreground">{PHASE_LABELS[phase].label}:</span>
|
||||
<span className="font-medium">{modelLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Phase Configuration */}
|
||||
{showPhaseDetails && (
|
||||
<div className="px-4 pb-4 space-y-4 border-t border-border pt-4">
|
||||
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => (
|
||||
<div key={phase} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
{PHASE_LABELS[phase].label}
|
||||
</Label>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{PHASE_LABELS[phase].description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Model</Label>
|
||||
<Select
|
||||
value={currentPhaseModels[phase]}
|
||||
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelType)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Thinking</Label>
|
||||
<Select
|
||||
value={currentPhaseThinking[phase]}
|
||||
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Configuration (shown only when custom is selected) */}
|
||||
{isCustom && (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-muted/30 p-4">
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-model" className="text-xs font-medium text-muted-foreground">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => onModelChange(value as ModelType)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="custom-model" className="h-9">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Thinking Level Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-thinking" className="text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</Label>
|
||||
<Select
|
||||
value={thinkingLevel}
|
||||
onValueChange={(value) => onThinkingLevelChange(value as ThinkingLevel)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="custom-thinking" className="h-9">
|
||||
<SelectValue placeholder="Select thinking level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{level.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
- {level.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const iconMap: Record<string, React.ElementType> = {
|
||||
*/
|
||||
export function AgentProfiles() {
|
||||
const settings = useSettingsStore((state) => state.settings);
|
||||
const selectedProfileId = settings.selectedAgentProfile || 'balanced';
|
||||
const selectedProfileId = settings.selectedAgentProfile || 'auto';
|
||||
|
||||
const handleSelectProfile = async (profileId: string) => {
|
||||
await saveSettings({ selectedAgentProfile: profileId });
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { TrendingUp, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './ui/dialog';
|
||||
import { Badge } from './ui/badge';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import type { CompetitorAnalysis } from '../../shared/types';
|
||||
|
||||
interface CompetitorAnalysisViewerProps {
|
||||
analysis: CompetitorAnalysis | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CompetitorAnalysisViewer({
|
||||
analysis,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CompetitorAnalysisViewerProps) {
|
||||
if (!analysis) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary" />
|
||||
Competitor Analysis Results
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Analyzed {analysis.competitors.length} competitors to identify market gaps and opportunities
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-auto pr-4" style={{ maxHeight: 'calc(85vh - 120px)' }}>
|
||||
<div className="space-y-6 pb-4">
|
||||
{analysis.competitors.map((competitor) => (
|
||||
<div
|
||||
key={competitor.id}
|
||||
className="rounded-lg border border-border p-4 space-y-3"
|
||||
>
|
||||
{/* Competitor Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold">{competitor.name}</h3>
|
||||
{competitor.marketPosition && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{competitor.marketPosition}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{competitor.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{competitor.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{competitor.url && (
|
||||
<a
|
||||
href={competitor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1 text-sm ml-4"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Visit
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pain Points */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-warning" />
|
||||
Identified Pain Points ({competitor.painPoints.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{competitor.painPoints.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No pain points identified
|
||||
</p>
|
||||
) : (
|
||||
competitor.painPoints.map((painPoint) => (
|
||||
<div
|
||||
key={painPoint.id}
|
||||
className="rounded bg-muted/50 p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
painPoint.severity === 'high'
|
||||
? 'destructive'
|
||||
: painPoint.severity === 'medium'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className="mt-0.5"
|
||||
>
|
||||
{painPoint.severity}
|
||||
</Badge>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{painPoint.description}
|
||||
</p>
|
||||
{painPoint.source && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Source: <span className="italic">{painPoint.source}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{painPoint.frequency && (
|
||||
<div className="mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Frequency: {painPoint.frequency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{painPoint.opportunity && (
|
||||
<div className="mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Opportunity:{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{painPoint.opportunity}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Insights Summary */}
|
||||
{analysis.insightsSummary && (
|
||||
<div className="rounded-lg bg-primary/5 border border-primary/20 p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold">Market Insights Summary</h4>
|
||||
|
||||
{analysis.insightsSummary.topPainPoints.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Top Pain Points:</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{analysis.insightsSummary.topPainPoints.map((point, idx) => (
|
||||
<li key={idx} className="text-muted-foreground">• {point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.insightsSummary.differentiatorOpportunities.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Differentiator Opportunities:</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{analysis.insightsSummary.differentiatorOpportunities.map((opp, idx) => (
|
||||
<li key={idx} className="text-muted-foreground">• {opp}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.insightsSummary.marketTrends.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Market Trends:</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{analysis.insightsSummary.marketTrends.map((trend, idx) => (
|
||||
<li key={idx} className="text-muted-foreground">• {trend}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { AVAILABLE_MODELS, THINKING_LEVELS } from '../../shared/constants';
|
||||
import type { InsightsModelConfig } from '../../shared/types';
|
||||
import type { ModelType, ThinkingLevel } from '../../shared/types';
|
||||
|
||||
interface CustomModelModalProps {
|
||||
currentConfig?: InsightsModelConfig;
|
||||
onSave: (config: InsightsModelConfig) => void;
|
||||
onClose: () => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export function CustomModelModal({ currentConfig, onSave, onClose, open = true }: CustomModelModalProps) {
|
||||
const [model, setModel] = useState<ModelType>(
|
||||
currentConfig?.model || 'sonnet'
|
||||
);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>(
|
||||
currentConfig?.thinkingLevel || 'medium'
|
||||
);
|
||||
|
||||
// Sync internal state when modal opens or config changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setModel(currentConfig?.model || 'sonnet');
|
||||
setThinkingLevel(currentConfig?.thinkingLevel || 'medium');
|
||||
}
|
||||
}, [open, currentConfig]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
profileId: 'custom',
|
||||
model,
|
||||
thinkingLevel
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom Model Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure the model and thinking level for this chat session.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-select">Model</Label>
|
||||
<Select value={model} onValueChange={(v) => setModel(v as ModelType)}>
|
||||
<SelectTrigger id="model-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="thinking-select">Thinking Level</Label>
|
||||
<Select value={thinkingLevel} onValueChange={(v) => setThinkingLevel(v as ThinkingLevel)}>
|
||||
<SelectTrigger id="thinking-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{level.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{level.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info
|
||||
Info,
|
||||
LogIn,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -26,6 +29,8 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from './ui/tooltip';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { ClaudeProfile } from '../../shared/types';
|
||||
|
||||
interface EnvConfigModalProps {
|
||||
open: boolean;
|
||||
@@ -33,6 +38,7 @@ interface EnvConfigModalProps {
|
||||
onConfigured?: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function EnvConfigModal({
|
||||
@@ -40,50 +46,168 @@ export function EnvConfigModal({
|
||||
onOpenChange,
|
||||
onConfigured,
|
||||
title = 'Claude Authentication Required',
|
||||
description = 'A Claude Code OAuth token is required to use AI features like Ideation and Roadmap generation.'
|
||||
description = 'A Claude Code OAuth token is required to use AI features like Ideation and Roadmap generation.',
|
||||
projectId
|
||||
}: EnvConfigModalProps) {
|
||||
const [token, setToken] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [_isLoading, _setIsLoading] = useState(false);
|
||||
const [showManualEntry, setShowManualEntry] = useState(false);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [sourcePath, setSourcePath] = useState<string | null>(null);
|
||||
const [hasExistingToken, setHasExistingToken] = useState(false);
|
||||
const [claudeProfiles, setClaudeProfiles] = useState<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
oauthToken?: string;
|
||||
email?: string;
|
||||
isDefault: boolean;
|
||||
}>>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [isLoadingProfiles, setIsLoadingProfiles] = useState(true);
|
||||
|
||||
// Check current token status when modal opens
|
||||
// Load Claude profiles and check token status when modal opens
|
||||
useEffect(() => {
|
||||
const checkToken = async () => {
|
||||
const loadData = async () => {
|
||||
if (!open) return;
|
||||
|
||||
setIsChecking(true);
|
||||
setIsLoadingProfiles(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.checkSourceToken();
|
||||
if (result.success && result.data) {
|
||||
setSourcePath(result.data.sourcePath || null);
|
||||
setHasExistingToken(result.data.hasToken);
|
||||
// Load both token status and Claude profiles in parallel
|
||||
const [tokenResult, profilesResult] = await Promise.all([
|
||||
window.electronAPI.checkSourceToken(),
|
||||
window.electronAPI.getClaudeProfiles()
|
||||
]);
|
||||
|
||||
if (result.data.hasToken) {
|
||||
// Handle token status
|
||||
if (tokenResult.success && tokenResult.data) {
|
||||
setSourcePath(tokenResult.data.sourcePath || null);
|
||||
setHasExistingToken(tokenResult.data.hasToken);
|
||||
|
||||
if (tokenResult.data.hasToken) {
|
||||
// Token exists, show success state
|
||||
setSuccess(true);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to check token status');
|
||||
setError(tokenResult.error || 'Failed to check token status');
|
||||
}
|
||||
|
||||
// Handle Claude profiles
|
||||
if (profilesResult.success && profilesResult.data) {
|
||||
const authenticatedProfiles = profilesResult.data.profiles.filter(
|
||||
(p: ClaudeProfile) => p.oauthToken || (p.isDefault && p.configDir)
|
||||
);
|
||||
setClaudeProfiles(authenticatedProfiles);
|
||||
|
||||
// Auto-select first authenticated profile
|
||||
if (authenticatedProfiles.length > 0 && !selectedProfileId) {
|
||||
setSelectedProfileId(authenticatedProfiles[0].id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
setIsLoadingProfiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkToken();
|
||||
loadData();
|
||||
}, [open]);
|
||||
|
||||
// Listen for OAuth token from terminal
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const cleanup = window.electronAPI.onTerminalOAuthToken(async (info) => {
|
||||
if (info.success) {
|
||||
// Token is auto-saved to the profile by the main process
|
||||
// Just update UI state to reflect authentication success
|
||||
setSuccess(true);
|
||||
setHasExistingToken(true);
|
||||
setIsAuthenticating(false);
|
||||
|
||||
// Notify parent
|
||||
setTimeout(() => {
|
||||
onConfigured?.();
|
||||
onOpenChange(false);
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [open, onConfigured, onOpenChange]);
|
||||
|
||||
const handleUseExistingProfile = async () => {
|
||||
if (!selectedProfileId) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get the selected profile's token
|
||||
const profile = claudeProfiles.find(p => p.id === selectedProfileId);
|
||||
if (!profile?.oauthToken) {
|
||||
setError('Selected profile does not have a valid token');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the token to auto-claude .env
|
||||
const result = await window.electronAPI.updateSourceEnv({
|
||||
claudeOAuthToken: profile.oauthToken
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
setHasExistingToken(true);
|
||||
|
||||
// Notify parent
|
||||
setTimeout(() => {
|
||||
onConfigured?.();
|
||||
onOpenChange(false);
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(result.error || 'Failed to save token');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthenticateWithBrowser = async () => {
|
||||
if (!projectId) {
|
||||
setError('No project selected. Please select a project first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthenticating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Invoke the Claude setup-token flow in terminal
|
||||
const result = await window.electronAPI.invokeClaudeSetup(projectId);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to start authentication');
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
// Keep isAuthenticating true - will be cleared when token is received
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start authentication');
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!token.trim()) {
|
||||
setError('Please enter a token');
|
||||
@@ -182,89 +306,258 @@ export function EnvConfigModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info about getting a token */}
|
||||
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
How to get a Claude Code OAuth token:
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Install Claude Code CLI if you haven't already</li>
|
||||
<li>
|
||||
Run{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded font-mono text-xs">
|
||||
claude setup-token
|
||||
</code>
|
||||
{' '}
|
||||
{/* Option 1: Use existing authenticated profile */}
|
||||
{!isLoadingProfiles && claudeProfiles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-success/10 border border-success/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-success shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground font-medium mb-1">
|
||||
Use Existing Account
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You have {claudeProfiles.length} authenticated Claude account{claudeProfiles.length > 1 ? 's' : ''}. Select one to use:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
Select Account
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{claudeProfiles.map((profile) => (
|
||||
<button
|
||||
onClick={handleCopyCommand}
|
||||
className="inline-flex items-center text-info hover:text-info/80"
|
||||
key={profile.id}
|
||||
onClick={() => setSelectedProfileId(profile.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left",
|
||||
selectedProfileId === profile.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
)}
|
||||
>
|
||||
<Copy className="h-3 w-3 ml-1" />
|
||||
<div className={cn(
|
||||
"h-4 w-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
selectedProfileId === profile.id
|
||||
? "border-primary"
|
||||
: "border-muted-foreground"
|
||||
)}>
|
||||
{selectedProfileId === profile.id && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{profile.name}
|
||||
{profile.isDefault && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">(Default)</span>
|
||||
)}
|
||||
</p>
|
||||
{profile.email && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{profile.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<CheckCircle2 className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
selectedProfileId === profile.id ? "text-primary" : "text-transparent"
|
||||
)} />
|
||||
</button>
|
||||
</li>
|
||||
<li>Copy the token and paste it below</li>
|
||||
</ol>
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="text-sm text-info hover:text-info/80 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View documentation
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleUseExistingProfile}
|
||||
disabled={!selectedProfileId || isSaving}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Use This Account
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token" className="text-sm font-medium text-foreground">
|
||||
Claude Code OAuth Token
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Enter your token..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showToken ? 'Hide token' : 'Show token'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Option 2: Authenticate new account with browser */}
|
||||
{!isLoadingProfiles && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground font-medium mb-1">
|
||||
{claudeProfiles.length > 0 ? 'Or Authenticate New Account' : 'Authenticate with Browser'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{claudeProfiles.length > 0
|
||||
? 'Add a new Claude account by logging in with your browser.'
|
||||
: 'Click below to open your browser and log in with your Claude account.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAuthenticateWithBrowser}
|
||||
disabled={isAuthenticating}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant={claudeProfiles.length > 0 ? "outline" : "default"}
|
||||
>
|
||||
{isAuthenticating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Waiting for authentication...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="mr-2 h-5 w-5" />
|
||||
{claudeProfiles.length > 0 ? 'Authenticate New Account' : 'Authenticate with Browser'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isAuthenticating && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
A browser window should open. Complete the authentication there, then return here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The token will be saved to{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded font-mono">
|
||||
{sourcePath ? `${sourcePath}/.env` : 'auto-claude/.env'}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Divider before manual entry */}
|
||||
{!isLoadingProfiles && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary: Manual Token Entry (Collapsible) */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowManualEntry(!showManualEntry)}
|
||||
className="w-full flex items-center justify-between text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Enter token manually</span>
|
||||
{showManualEntry ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showManualEntry && (
|
||||
<div className="space-y-3 pl-4 border-l-2 border-border">
|
||||
{/* Manual token instructions */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium text-foreground">Steps:</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Install Claude Code CLI if you haven't already</li>
|
||||
<li>
|
||||
Run{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded font-mono">
|
||||
claude setup-token
|
||||
</code>
|
||||
{' '}
|
||||
<button
|
||||
onClick={handleCopyCommand}
|
||||
className="inline-flex items-center text-info hover:text-info/80"
|
||||
>
|
||||
<Copy className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</li>
|
||||
<li>Copy the token and paste it below</li>
|
||||
</ol>
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="text-info hover:text-info/80 flex items-center gap-1 mt-2"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View documentation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Token input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token" className="text-sm font-medium text-foreground">
|
||||
Claude Code OAuth Token
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Enter your token..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isAuthenticating}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showToken ? 'Hide token' : 'Show token'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The token will be saved to{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded font-mono">
|
||||
{sourcePath ? `${sourcePath}/.env` : 'auto-claude/.env'}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing token info */}
|
||||
{hasExistingToken && (
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A token is already configured. Enter a new token above to replace it.
|
||||
A token is already configured. {showManualEntry ? 'Enter a new token above to replace it.' : 'Authenticate again to replace it.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -272,11 +565,11 @@ export function EnvConfigModal({
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSaving || isAuthenticating}>
|
||||
{success ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
{!success && (
|
||||
<Button onClick={handleSave} disabled={!token.trim() || isSaving}>
|
||||
{!success && showManualEntry && token.trim() && (
|
||||
<Button onClick={handleSave} disabled={isSaving || isAuthenticating}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Globe, RefreshCw, TrendingUp, CheckCircle } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/alert-dialog';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface ExistingCompetitorAnalysisDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUseExisting: () => void;
|
||||
onRunNew: () => void;
|
||||
onSkip: () => void;
|
||||
analysisDate?: Date;
|
||||
}
|
||||
|
||||
export function ExistingCompetitorAnalysisDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onUseExisting,
|
||||
onRunNew,
|
||||
onSkip,
|
||||
analysisDate,
|
||||
}: ExistingCompetitorAnalysisDialogProps) {
|
||||
const handleUseExisting = () => {
|
||||
onUseExisting();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleRunNew = () => {
|
||||
onRunNew();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const formatDate = (date?: Date) => {
|
||||
if (!date) return 'recently';
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="sm:max-w-[500px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<TrendingUp className="h-5 w-5 text-primary" />
|
||||
Competitor Analysis Options
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-muted-foreground">
|
||||
This project has an existing competitor analysis from {formatDate(analysisDate)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="py-4 space-y-3">
|
||||
{/* Option 1: Use existing (recommended) */}
|
||||
<button
|
||||
onClick={handleUseExisting}
|
||||
className="w-full rounded-lg bg-primary/10 border border-primary/30 p-4 text-left hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
Use existing analysis
|
||||
<span className="text-xs text-primary font-normal">(Recommended)</span>
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Reuse the competitor insights you already have. Faster and no additional web searches.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Option 2: Run new analysis */}
|
||||
<button
|
||||
onClick={handleRunNew}
|
||||
className="w-full rounded-lg bg-muted/50 border border-border p-4 text-left hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<RefreshCw className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
Run new analysis
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Perform fresh web searches to get updated competitor information. Takes longer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Option 3: Skip */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-full rounded-lg bg-muted/30 border border-border/50 p-4 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Globe className="h-5 w-5 text-muted-foreground/60 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Skip competitor analysis
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground/80 mt-1">
|
||||
Generate roadmap without any competitor insights.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className="sm:justify-start">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { useState, useRef, useEffect, type DragEvent } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder, File, FileCode, FileJson, FileText, FileImage, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { FileNode } from '../../shared/types';
|
||||
@@ -70,15 +70,19 @@ export function FileTreeItem({
|
||||
isLoading,
|
||||
onToggle,
|
||||
}: FileTreeItemProps) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: node.path,
|
||||
data: {
|
||||
type: 'file',
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
isDirectory: node.isDirectory
|
||||
}
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragImageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Cleanup drag image on unmount to prevent memory leaks
|
||||
// This handles cases where component unmounts mid-drag or dragend doesn't fire
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||
dragImageRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -94,15 +98,62 @@ export function FileTreeItem({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
|
||||
// Set the drag data as JSON
|
||||
const dragData = {
|
||||
type: 'file-reference',
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
isDirectory: node.isDirectory
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
||||
e.dataTransfer.setData('text/plain', `@${node.name}`);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
// Create a custom drag image using safe DOM manipulation (no innerHTML)
|
||||
const dragImage = document.createElement('div');
|
||||
dragImage.className = 'flex items-center gap-2 bg-card border border-primary rounded-md px-3 py-2 shadow-lg text-sm';
|
||||
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.textContent = node.isDirectory ? '📁' : '📄';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = node.name;
|
||||
|
||||
dragImage.appendChild(iconSpan);
|
||||
dragImage.appendChild(nameSpan);
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-1000px';
|
||||
dragImage.style.left = '-1000px';
|
||||
document.body.appendChild(dragImage);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
|
||||
// Store reference for cleanup in dragend
|
||||
dragImageRef.current = dragImage;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
|
||||
// Clean up drag image element
|
||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||
dragImageRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
'flex items-center gap-1 py-1 px-2 rounded cursor-grab select-none',
|
||||
'hover:bg-accent/50 transition-colors',
|
||||
isDragging && 'opacity-50 bg-accent'
|
||||
isDragging && 'opacity-50 bg-accent ring-2 ring-primary'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import { useTaskStore } from '../stores/task-store';
|
||||
import { useGitHubIssues, useGitHubInvestigation, useIssueFiltering } from './github-issues/hooks';
|
||||
import {
|
||||
NotConnectedState,
|
||||
@@ -12,10 +13,11 @@ import {
|
||||
import type { GitHubIssue } from '../../shared/types';
|
||||
import type { GitHubIssuesProps } from './github-issues/types';
|
||||
|
||||
export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesProps) {
|
||||
const projects = useProjectStore((state) => state.projects);
|
||||
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
const tasks = useTaskStore((state) => state.tasks);
|
||||
|
||||
const {
|
||||
issues,
|
||||
@@ -43,14 +45,25 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
const [showInvestigateDialog, setShowInvestigateDialog] = useState(false);
|
||||
const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] = useState<GitHubIssue | null>(null);
|
||||
|
||||
// Build a map of GitHub issue numbers to task IDs for quick lookup
|
||||
const issueToTaskMap = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const task of tasks) {
|
||||
if (task.metadata?.githubIssueNumber) {
|
||||
map.set(task.metadata.githubIssueNumber, task.specId || task.id);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
const handleInvestigate = useCallback((issue: GitHubIssue) => {
|
||||
setSelectedIssueForInvestigation(issue);
|
||||
setShowInvestigateDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleStartInvestigation = useCallback(() => {
|
||||
const handleStartInvestigation = useCallback((selectedCommentIds: number[]) => {
|
||||
if (selectedIssueForInvestigation) {
|
||||
startInvestigation(selectedIssueForInvestigation);
|
||||
startInvestigation(selectedIssueForInvestigation, selectedCommentIds);
|
||||
}
|
||||
}, [selectedIssueForInvestigation, startInvestigation]);
|
||||
|
||||
@@ -110,6 +123,8 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
? lastInvestigationResult
|
||||
: null
|
||||
}
|
||||
linkedTaskId={issueToTaskMap.get(selectedIssue.number)}
|
||||
onViewTask={onNavigateToTask}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message="Select an issue to view details" />
|
||||
@@ -125,6 +140,7 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
investigationStatus={investigationStatus}
|
||||
onStartInvestigation={handleStartInvestigation}
|
||||
onClose={handleCloseDialog}
|
||||
projectId={selectedProject?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Github,
|
||||
GitBranch,
|
||||
Key,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { GitHubOAuthFlow } from './project-settings/GitHubOAuthFlow';
|
||||
import { ClaudeOAuthFlow } from './project-settings/ClaudeOAuthFlow';
|
||||
import type { Project, ProjectSettings } from '../../shared/types';
|
||||
|
||||
interface GitHubSetupModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: Project;
|
||||
onComplete: (settings: { githubToken: string; githubRepo: string; mainBranch: string }) => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
type SetupStep = 'github-auth' | 'claude-auth' | 'repo' | 'branch' | 'complete';
|
||||
|
||||
/**
|
||||
* Setup Modal - Required setup flow after Auto Claude initialization
|
||||
*
|
||||
* Flow:
|
||||
* 1. Authenticate with GitHub (via gh CLI OAuth) - for repo operations
|
||||
* 2. Authenticate with Claude (via claude CLI OAuth) - for AI features
|
||||
* 3. Detect/confirm repository
|
||||
* 4. Select base branch for tasks (with recommended default)
|
||||
*/
|
||||
export function GitHubSetupModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
onComplete,
|
||||
onSkip
|
||||
}: GitHubSetupModalProps) {
|
||||
const [step, setStep] = useState<SetupStep>('github-auth');
|
||||
const [githubToken, setGithubToken] = useState<string | null>(null);
|
||||
const [githubRepo, setGithubRepo] = useState<string | null>(null);
|
||||
const [detectedRepo, setDetectedRepo] = useState<string | null>(null);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||
const [recommendedBranch, setRecommendedBranch] = useState<string | null>(null);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('github-auth');
|
||||
setGithubToken(null);
|
||||
setGithubRepo(null);
|
||||
setDetectedRepo(null);
|
||||
setBranches([]);
|
||||
setSelectedBranch(null);
|
||||
setRecommendedBranch(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Detect repository from git remote when auth succeeds
|
||||
const detectRepository = async () => {
|
||||
setIsLoadingRepo(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to detect repo from git remote
|
||||
const result = await window.electronAPI.detectGitHubRepo(project.path);
|
||||
if (result.success && result.data) {
|
||||
setDetectedRepo(result.data);
|
||||
setGithubRepo(result.data);
|
||||
setStep('branch');
|
||||
// Immediately load branches
|
||||
await loadBranches(result.data);
|
||||
} else {
|
||||
// No remote detected, show repo input step
|
||||
setStep('repo');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect repository');
|
||||
setStep('repo');
|
||||
} finally {
|
||||
setIsLoadingRepo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load branches from GitHub
|
||||
const loadBranches = async (repo: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get branches from GitHub API
|
||||
const result = await window.electronAPI.getGitHubBranches(repo, githubToken!);
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
|
||||
// Detect recommended branch (main > master > develop > first)
|
||||
const recommended = detectRecommendedBranch(result.data);
|
||||
setRecommendedBranch(recommended);
|
||||
setSelectedBranch(recommended);
|
||||
} else {
|
||||
setError(result.error || 'Failed to load branches');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load branches');
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Detect recommended branch from list
|
||||
const detectRecommendedBranch = (branchList: string[]): string | null => {
|
||||
const priorities = ['main', 'master', 'develop', 'dev'];
|
||||
for (const priority of priorities) {
|
||||
if (branchList.includes(priority)) {
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
return branchList[0] || null;
|
||||
};
|
||||
|
||||
// Handle GitHub OAuth success
|
||||
const handleGitHubAuthSuccess = async (token: string) => {
|
||||
setGithubToken(token);
|
||||
// Move to Claude auth step
|
||||
setStep('claude-auth');
|
||||
};
|
||||
|
||||
// Handle Claude OAuth success
|
||||
const handleClaudeAuthSuccess = async () => {
|
||||
// Claude token is already saved to active profile by the OAuth flow
|
||||
// Move to repo detection
|
||||
await detectRepository();
|
||||
};
|
||||
|
||||
// Handle branch selection complete
|
||||
const handleComplete = () => {
|
||||
if (githubToken && githubRepo && selectedBranch) {
|
||||
onComplete({
|
||||
githubToken,
|
||||
githubRepo,
|
||||
mainBranch: selectedBranch
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render step content
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'github-auth':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
Connect to GitHub
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auto Claude requires GitHub to manage your code branches and keep tasks up to date.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<GitHubOAuthFlow
|
||||
onSuccess={handleGitHubAuthSuccess}
|
||||
onCancel={onSkip}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'claude-auth':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
Connect to Claude AI
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auto Claude uses Claude AI for intelligent features like Roadmap generation, Task automation, and Ideation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<ClaudeOAuthFlow
|
||||
onSuccess={handleClaudeAuthSuccess}
|
||||
onCancel={onSkip}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'repo':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
Repository Not Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
We couldn't detect a GitHub repository for this project. Please ensure your project has a GitHub remote configured.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-warning mt-0.5" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">No GitHub remote found</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
To use Auto Claude, your project needs to be connected to a GitHub repository.
|
||||
</p>
|
||||
<div className="text-xs font-mono bg-muted p-2 rounded mt-2">
|
||||
git remote add origin https://github.com/owner/repo.git
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onSkip && (
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={detectRepository} disabled={isLoadingRepo}>
|
||||
{isLoadingRepo ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
'Retry Detection'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'branch':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
Select Base Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which branch Auto Claude should use as the base for creating task branches.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Show detected repo */}
|
||||
{detectedRepo && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Github className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Repository:</span>
|
||||
<code className="px-2 py-0.5 bg-muted rounded font-mono text-xs">
|
||||
{detectedRepo}
|
||||
</code>
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Base Branch</Label>
|
||||
<Select
|
||||
value={selectedBranch || ''}
|
||||
onValueChange={setSelectedBranch}
|
||||
disabled={isLoadingBranches || branches.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{isLoadingBranches ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Loading branches...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Select a branch" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branches.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{branch}</span>
|
||||
{branch === recommendedBranch && (
|
||||
<span className="flex items-center gap-1 text-xs text-success">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All tasks will be created from branches like{' '}
|
||||
<code className="px-1 bg-muted rounded">auto-claude/task-name</code>
|
||||
{selectedBranch && (
|
||||
<> based on <code className="px-1 bg-muted rounded">{selectedBranch}</code></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info about branch selection */}
|
||||
<div className="rounded-lg border border-info/30 bg-info/5 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-info mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Why select a branch?</p>
|
||||
<p className="mt-1">
|
||||
Auto Claude creates isolated workspaces for each task. Selecting the right base branch ensures
|
||||
your tasks start with the latest code from your main development line.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onSkip && (
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={!selectedBranch || isLoadingBranches}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Complete Setup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'complete':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
Setup Complete
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-8 flex flex-col items-center justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
|
||||
<CheckCircle2 className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Auto Claude is ready to use! You can now create tasks that will be
|
||||
automatically based on <code className="px-1 bg-muted rounded">{selectedBranch}</code>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Progress indicator
|
||||
const renderProgress = () => {
|
||||
const steps: { label: string }[] = [
|
||||
{ label: 'Authenticate' },
|
||||
{ label: 'Configure' },
|
||||
];
|
||||
|
||||
// Don't show progress on complete step
|
||||
if (step === 'complete') return null;
|
||||
|
||||
// Map steps to progress indices
|
||||
// Auth steps (github-auth, claude-auth, repo) = 0
|
||||
// Config steps (branch) = 1
|
||||
const currentIndex =
|
||||
step === 'github-auth' ? 0 :
|
||||
step === 'claude-auth' ? 0 :
|
||||
step === 'repo' ? 0 :
|
||||
1;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
{steps.map((s, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
|
||||
index < currentIndex
|
||||
? 'bg-success text-success-foreground'
|
||||
: index === currentIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index < currentIndex ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span className={`ml-2 text-xs ${
|
||||
index === currentIndex ? 'text-foreground font-medium' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
{index < steps.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 mx-2 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{renderProgress()}
|
||||
{renderStepContent()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -29,12 +29,14 @@ import {
|
||||
switchSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
updateModelConfig,
|
||||
createTaskFromSuggestion,
|
||||
setupInsightsListeners
|
||||
} from '../stores/insights-store';
|
||||
import { loadTasks } from '../stores/task-store';
|
||||
import { ChatHistorySidebar } from './ChatHistorySidebar';
|
||||
import type { InsightsChatMessage } from '../../shared/types';
|
||||
import { InsightsModelSelector } from './InsightsModelSelector';
|
||||
import type { InsightsChatMessage, InsightsModelConfig } from '../../shared/types';
|
||||
import {
|
||||
TASK_CATEGORY_LABELS,
|
||||
TASK_CATEGORY_COLORS,
|
||||
@@ -141,6 +143,13 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelConfigChange = async (config: InsightsModelConfig) => {
|
||||
// If we have a session, persist the config
|
||||
if (session?.id) {
|
||||
await updateModelConfig(projectId, session.id, config);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = status.phase === 'thinking' || status.phase === 'streaming';
|
||||
const messages = session?.messages || [];
|
||||
|
||||
@@ -187,14 +196,21 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewSession}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<InsightsModelSelector
|
||||
currentConfig={session?.modelConfig}
|
||||
onConfigChange={handleModelConfigChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewSession}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Sparkles, Sliders, Check } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel
|
||||
} from './ui/dropdown-menu';
|
||||
import { DEFAULT_AGENT_PROFILES, AVAILABLE_MODELS } from '../../shared/constants';
|
||||
import type { InsightsModelConfig } from '../../shared/types';
|
||||
import { CustomModelModal } from './CustomModelModal';
|
||||
|
||||
interface InsightsModelSelectorProps {
|
||||
currentConfig?: InsightsModelConfig;
|
||||
onConfigChange: (config: InsightsModelConfig) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Brain,
|
||||
Scale,
|
||||
Zap,
|
||||
Sparkles
|
||||
};
|
||||
|
||||
export function InsightsModelSelector({
|
||||
currentConfig,
|
||||
onConfigChange,
|
||||
disabled
|
||||
}: InsightsModelSelectorProps) {
|
||||
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||
|
||||
// Default to 'balanced' if no config, or if 'auto' profile was selected (not applicable for insights)
|
||||
const rawProfileId = currentConfig?.profileId || 'balanced';
|
||||
const selectedProfileId = rawProfileId === 'auto' ? 'balanced' : rawProfileId;
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedProfileId);
|
||||
|
||||
// Get the appropriate icon
|
||||
const Icon = selectedProfileId === 'custom'
|
||||
? Sliders
|
||||
: (profile?.icon ? iconMap[profile.icon] : Scale);
|
||||
|
||||
const handleSelectProfile = (profileId: string) => {
|
||||
if (profileId === 'custom') {
|
||||
setShowCustomModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId);
|
||||
if (selected) {
|
||||
onConfigChange({
|
||||
profileId: selected.id,
|
||||
model: selected.model,
|
||||
thinkingLevel: selected.thinkingLevel
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomSave = (config: InsightsModelConfig) => {
|
||||
onConfigChange(config);
|
||||
setShowCustomModal(false);
|
||||
};
|
||||
|
||||
// Build display text for current selection
|
||||
const getDisplayText = () => {
|
||||
if (selectedProfileId === 'custom' && currentConfig) {
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentConfig.model)?.label || currentConfig.model;
|
||||
return `${modelLabel} + ${currentConfig.thinkingLevel}`;
|
||||
}
|
||||
return profile?.name || 'Balanced';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-2 px-2"
|
||||
disabled={disabled}
|
||||
title={`Model: ${getDisplayText()}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
{getDisplayText()}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>Agent Profile</DropdownMenuLabel>
|
||||
{DEFAULT_AGENT_PROFILES.filter(p => !p.isAutoProfile).map((p) => {
|
||||
const ProfileIcon = iconMap[p.icon || 'Brain'];
|
||||
const isSelected = selectedProfileId === p.id;
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === p.model)?.label;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
onClick={() => handleSelectProfile(p.id)}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<ProfileIcon className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{modelLabel} + {p.thinkingLevel}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelectProfile('custom')}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Sliders className="h-4 w-4 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Custom...</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Choose model & thinking level
|
||||
</div>
|
||||
</div>
|
||||
{selectedProfileId === 'custom' && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CustomModelModal
|
||||
open={showCustomModal}
|
||||
currentConfig={currentConfig}
|
||||
onSave={handleCustomSave}
|
||||
onClose={() => setShowCustomModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export function PhaseProgressIndicator({
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{subtasks.slice(0, 10).map((subtask, index) => (
|
||||
<motion.div
|
||||
key={subtask.id}
|
||||
key={subtask.id || `subtask-${index}`}
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
subtask.status === 'completed' && 'bg-success',
|
||||
@@ -185,7 +185,7 @@ export function PhaseProgressIndicator({
|
||||
/>
|
||||
))}
|
||||
{totalSubtasks > 10 && (
|
||||
<span className="text-[10px] text-muted-foreground font-medium ml-0.5">
|
||||
<span key="overflow-count" className="text-[10px] text-muted-foreground font-medium ml-0.5">
|
||||
+{totalSubtasks - 10}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -238,6 +238,7 @@ export function ProjectSettings({ project, open, onOpenChange }: ProjectSettings
|
||||
onUpdateConfig={updateEnvConfig}
|
||||
gitHubConnectionStatus={gitHubConnectionStatus}
|
||||
isCheckingGitHub={isCheckingGitHub}
|
||||
projectName={project.name}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { RoadmapGenerationProgress } from './RoadmapGenerationProgress';
|
||||
import { CompetitorAnalysisDialog } from './CompetitorAnalysisDialog';
|
||||
import { ExistingCompetitorAnalysisDialog } from './ExistingCompetitorAnalysisDialog';
|
||||
import { CompetitorAnalysisViewer } from './CompetitorAnalysisViewer';
|
||||
import { AddFeatureDialog } from './AddFeatureDialog';
|
||||
import { RoadmapHeader } from './roadmap/RoadmapHeader';
|
||||
import { RoadmapEmptyState } from './roadmap/RoadmapEmptyState';
|
||||
import { RoadmapTabs } from './roadmap/RoadmapTabs';
|
||||
import { FeatureDetailPanel } from './roadmap/FeatureDetailPanel';
|
||||
import { useRoadmapData, useFeatureActions, useRoadmapGeneration } from './roadmap/hooks';
|
||||
import { useRoadmapData, useFeatureActions, useRoadmapGeneration, useRoadmapSave, useFeatureDelete } from './roadmap/hooks';
|
||||
import { getCompetitorInsightsForFeature } from './roadmap/utils';
|
||||
import type { RoadmapFeature } from '../../shared/types';
|
||||
import type { RoadmapProps } from './roadmap/types';
|
||||
@@ -14,19 +16,31 @@ import type { RoadmapProps } from './roadmap/types';
|
||||
export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
// State management
|
||||
const [selectedFeature, setSelectedFeature] = useState<RoadmapFeature | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('phases');
|
||||
const [activeTab, setActiveTab] = useState('kanban');
|
||||
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
|
||||
const [showCompetitorViewer, setShowCompetitorViewer] = useState(false);
|
||||
|
||||
// Custom hooks
|
||||
const { roadmap, competitorAnalysis, generationStatus } = useRoadmapData(projectId);
|
||||
const { convertFeatureToSpec } = useFeatureActions();
|
||||
const { saveRoadmap } = useRoadmapSave(projectId);
|
||||
const { deleteFeature } = useFeatureDelete(projectId);
|
||||
const {
|
||||
competitorAnalysisDate,
|
||||
// New dialog for existing analysis
|
||||
showExistingAnalysisDialog,
|
||||
setShowExistingAnalysisDialog,
|
||||
handleUseExistingAnalysis,
|
||||
handleRunNewAnalysis,
|
||||
handleSkipAnalysis,
|
||||
// Original dialog for no existing analysis
|
||||
showCompetitorDialog,
|
||||
setShowCompetitorDialog,
|
||||
handleGenerate,
|
||||
handleRefresh,
|
||||
handleCompetitorDialogAccept,
|
||||
handleCompetitorDialogDecline,
|
||||
handleStop,
|
||||
} = useRoadmapGeneration(projectId);
|
||||
|
||||
// Event handlers
|
||||
@@ -47,6 +61,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
<RoadmapGenerationProgress
|
||||
generationStatus={generationStatus}
|
||||
className="w-full max-w-md"
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -57,12 +72,22 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
return (
|
||||
<>
|
||||
<RoadmapEmptyState onGenerate={handleGenerate} />
|
||||
{/* Dialog for projects WITHOUT existing competitor analysis */}
|
||||
<CompetitorAnalysisDialog
|
||||
open={showCompetitorDialog}
|
||||
onOpenChange={setShowCompetitorDialog}
|
||||
onAccept={handleCompetitorDialogAccept}
|
||||
onDecline={handleCompetitorDialogDecline}
|
||||
/>
|
||||
{/* Dialog for projects WITH existing competitor analysis */}
|
||||
<ExistingCompetitorAnalysisDialog
|
||||
open={showExistingAnalysisDialog}
|
||||
onOpenChange={setShowExistingAnalysisDialog}
|
||||
onUseExisting={handleUseExistingAnalysis}
|
||||
onRunNew={handleRunNewAnalysis}
|
||||
onSkip={handleSkipAnalysis}
|
||||
analysisDate={competitorAnalysisDate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -73,8 +98,10 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
{/* Header */}
|
||||
<RoadmapHeader
|
||||
roadmap={roadmap}
|
||||
competitorAnalysis={competitorAnalysis}
|
||||
onAddFeature={() => setShowAddFeatureDialog(true)}
|
||||
onRefresh={handleRefresh}
|
||||
onViewCompetitorAnalysis={() => setShowCompetitorViewer(true)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
@@ -86,6 +113,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
onFeatureSelect={setSelectedFeature}
|
||||
onConvertToSpec={handleConvertToSpec}
|
||||
onGoToTask={handleGoToTask}
|
||||
onSave={saveRoadmap}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -96,11 +124,12 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
onClose={() => setSelectedFeature(null)}
|
||||
onConvertToSpec={handleConvertToSpec}
|
||||
onGoToTask={handleGoToTask}
|
||||
onDelete={deleteFeature}
|
||||
competitorInsights={getCompetitorInsightsForFeature(selectedFeature, competitorAnalysis)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Competitor Analysis Permission Dialog */}
|
||||
{/* Competitor Analysis Permission Dialog (no existing analysis) */}
|
||||
<CompetitorAnalysisDialog
|
||||
open={showCompetitorDialog}
|
||||
onOpenChange={setShowCompetitorDialog}
|
||||
@@ -108,6 +137,23 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
onDecline={handleCompetitorDialogDecline}
|
||||
/>
|
||||
|
||||
{/* Competitor Analysis Options Dialog (existing analysis) */}
|
||||
<ExistingCompetitorAnalysisDialog
|
||||
open={showExistingAnalysisDialog}
|
||||
onOpenChange={setShowExistingAnalysisDialog}
|
||||
onUseExisting={handleUseExistingAnalysis}
|
||||
onRunNew={handleRunNewAnalysis}
|
||||
onSkip={handleSkipAnalysis}
|
||||
analysisDate={competitorAnalysisDate}
|
||||
/>
|
||||
|
||||
{/* Competitor Analysis Viewer */}
|
||||
<CompetitorAnalysisViewer
|
||||
analysis={competitorAnalysis}
|
||||
open={showCompetitorViewer}
|
||||
onOpenChange={setShowCompetitorViewer}
|
||||
/>
|
||||
|
||||
{/* Add Feature Dialog */}
|
||||
<AddFeatureDialog
|
||||
phases={roadmap.phases}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Search, Users, Sparkles, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Search, Users, Sparkles, CheckCircle2, AlertCircle, Square } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { RoadmapGenerationStatus } from '../../shared/types/roadmap';
|
||||
|
||||
@@ -36,6 +38,7 @@ function useReducedMotion(): boolean {
|
||||
interface RoadmapGenerationProgressProps {
|
||||
generationStatus: RoadmapGenerationStatus;
|
||||
className?: string;
|
||||
onStop?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
// Type for generation phases (excluding idle)
|
||||
@@ -190,9 +193,27 @@ function PhaseStepsIndicator({
|
||||
export function RoadmapGenerationProgress({
|
||||
generationStatus,
|
||||
className,
|
||||
onStop
|
||||
}: RoadmapGenerationProgressProps) {
|
||||
const { phase, progress, message, error } = generationStatus;
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
|
||||
/**
|
||||
* Handle stop button click with error handling and double-click prevention
|
||||
*/
|
||||
const handleStopClick = async () => {
|
||||
if (!onStop || isStopping) return;
|
||||
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await onStop();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop generation:', err);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render anything for idle phase
|
||||
if (phase === 'idle') {
|
||||
@@ -248,6 +269,26 @@ export function RoadmapGenerationProgress({
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4 p-6 rounded-xl bg-card border', className)}>
|
||||
{/* Header with Stop button */}
|
||||
{isActivePhase && onStop && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleStopClick}
|
||||
disabled={isStopping}
|
||||
>
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
{isStopping ? 'Stopping...' : 'Stop'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stop generation</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main phase display */}
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
{/* Animated icon with pulsing animation for active phase */}
|
||||
|
||||
@@ -18,17 +18,18 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
arrayMove
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Inbox } from 'lucide-react';
|
||||
import { Plus, Inbox, Eye, Calendar, Play, Check } from 'lucide-react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Card } from './ui/card';
|
||||
import { SortableFeatureCard } from './SortableFeatureCard';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useRoadmapStore } from '../stores/roadmap-store';
|
||||
import {
|
||||
useRoadmapStore,
|
||||
getFeaturesByPhase
|
||||
} from '../stores/roadmap-store';
|
||||
import type { RoadmapFeature, RoadmapPhase, Roadmap } from '../../shared/types';
|
||||
ROADMAP_STATUS_COLUMNS,
|
||||
type RoadmapStatusColumn
|
||||
} from '../../shared/constants';
|
||||
import type { RoadmapFeature, RoadmapFeatureStatus, Roadmap } from '../../shared/types';
|
||||
|
||||
interface RoadmapKanbanViewProps {
|
||||
roadmap: Roadmap;
|
||||
@@ -38,37 +39,43 @@ interface RoadmapKanbanViewProps {
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
interface DroppablePhaseColumnProps {
|
||||
phase: RoadmapPhase;
|
||||
interface DroppableStatusColumnProps {
|
||||
column: RoadmapStatusColumn;
|
||||
features: RoadmapFeature[];
|
||||
roadmap: Roadmap;
|
||||
onFeatureClick: (feature: RoadmapFeature) => void;
|
||||
onConvertToSpec?: (feature: RoadmapFeature) => void;
|
||||
onGoToTask?: (specId: string) => void;
|
||||
isOver: boolean;
|
||||
}
|
||||
|
||||
// Get phase status color for column header
|
||||
function getPhaseStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'border-t-success';
|
||||
case 'in_progress':
|
||||
return 'border-t-primary';
|
||||
// Get icon component for status
|
||||
function getStatusIcon(iconName: string) {
|
||||
switch (iconName) {
|
||||
case 'Eye':
|
||||
return <Eye className="h-3.5 w-3.5" />;
|
||||
case 'Calendar':
|
||||
return <Calendar className="h-3.5 w-3.5" />;
|
||||
case 'Play':
|
||||
return <Play className="h-3.5 w-3.5" />;
|
||||
case 'Check':
|
||||
return <Check className="h-3.5 w-3.5" />;
|
||||
default:
|
||||
return 'border-t-muted-foreground/30';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function DroppablePhaseColumn({
|
||||
phase,
|
||||
function DroppableStatusColumn({
|
||||
column,
|
||||
features,
|
||||
roadmap,
|
||||
onFeatureClick,
|
||||
onConvertToSpec,
|
||||
onGoToTask,
|
||||
isOver
|
||||
}: DroppablePhaseColumnProps) {
|
||||
}: DroppableStatusColumnProps) {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: phase.id
|
||||
id: column.id
|
||||
});
|
||||
|
||||
const featureIds = features.map((f) => f.id);
|
||||
@@ -78,7 +85,7 @@ function DroppablePhaseColumn({
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex min-w-80 w-80 shrink-0 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
getPhaseStatusColor(phase.status),
|
||||
column.color,
|
||||
'border-t-2',
|
||||
isOver && 'drop-zone-highlight'
|
||||
)}
|
||||
@@ -87,29 +94,26 @@ function DroppablePhaseColumn({
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
phase.status === 'completed'
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center',
|
||||
column.id === 'done'
|
||||
? 'bg-success/10 text-success'
|
||||
: phase.status === 'in_progress'
|
||||
: column.id === 'in_progress'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: column.id === 'planned'
|
||||
? 'bg-info/10 text-info'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{phase.order}
|
||||
{getStatusIcon(column.icon)}
|
||||
</div>
|
||||
<h2 className="font-semibold text-sm text-foreground truncate max-w-[180px]">
|
||||
{phase.name}
|
||||
<h2 className="font-semibold text-sm text-foreground">
|
||||
{column.label}
|
||||
</h2>
|
||||
<span className="column-count-badge">
|
||||
{features.length}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={phase.status === 'completed' ? 'default' : 'outline'}
|
||||
className="text-xs"
|
||||
>
|
||||
{phase.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Features list */}
|
||||
@@ -151,6 +155,7 @@ function DroppablePhaseColumn({
|
||||
<SortableFeatureCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
roadmap={roadmap}
|
||||
onClick={() => onFeatureClick(feature)}
|
||||
onConvertToSpec={onConvertToSpec}
|
||||
onGoToTask={onGoToTask}
|
||||
@@ -175,8 +180,7 @@ export function RoadmapKanbanView({
|
||||
const [activeFeature, setActiveFeature] = useState<RoadmapFeature | null>(null);
|
||||
const [overColumnId, setOverColumnId] = useState<string | null>(null);
|
||||
|
||||
const reorderFeatures = useRoadmapStore((state) => state.reorderFeatures);
|
||||
const updateFeaturePhase = useRoadmapStore((state) => state.updateFeaturePhase);
|
||||
const updateFeatureStatus = useRoadmapStore((state) => state.updateFeatureStatus);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -189,17 +193,17 @@ export function RoadmapKanbanView({
|
||||
})
|
||||
);
|
||||
|
||||
// Get features grouped by phase
|
||||
const featuresByPhase = useMemo(() => {
|
||||
// Get features grouped by status
|
||||
const featuresByStatus = useMemo(() => {
|
||||
const grouped: Record<string, RoadmapFeature[]> = {};
|
||||
roadmap.phases.forEach((phase) => {
|
||||
grouped[phase.id] = getFeaturesByPhase(roadmap, phase.id);
|
||||
ROADMAP_STATUS_COLUMNS.forEach((column) => {
|
||||
grouped[column.id] = roadmap.features.filter((f) => f.status === column.id);
|
||||
});
|
||||
return grouped;
|
||||
}, [roadmap]);
|
||||
}, [roadmap.features]);
|
||||
|
||||
// Get all phase IDs for detecting column drops
|
||||
const phaseIds = useMemo(() => roadmap.phases.map((p) => p.id), [roadmap.phases]);
|
||||
// Get all status IDs for detecting column drops
|
||||
const statusIds = useMemo(() => ROADMAP_STATUS_COLUMNS.map((c) => c.id), []);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
@@ -219,16 +223,16 @@ export function RoadmapKanbanView({
|
||||
|
||||
const overId = over.id as string;
|
||||
|
||||
// Check if over a phase column
|
||||
if (phaseIds.includes(overId)) {
|
||||
// Check if over a status column
|
||||
if (statusIds.includes(overId)) {
|
||||
setOverColumnId(overId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if over a feature - get its phase
|
||||
// Check if over a feature - get its status
|
||||
const overFeature = roadmap.features.find((f) => f.id === overId);
|
||||
if (overFeature) {
|
||||
setOverColumnId(overFeature.phaseId);
|
||||
setOverColumnId(overFeature.status);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,59 +249,36 @@ export function RoadmapKanbanView({
|
||||
|
||||
if (!draggedFeature) return;
|
||||
|
||||
// Determine target phase
|
||||
let targetPhaseId: string;
|
||||
let targetFeatureIndex: number = -1;
|
||||
// Determine target status
|
||||
let targetStatus: RoadmapFeatureStatus;
|
||||
|
||||
if (phaseIds.includes(overId)) {
|
||||
// Dropped directly on a phase column
|
||||
targetPhaseId = overId;
|
||||
if (statusIds.includes(overId)) {
|
||||
// Dropped directly on a status column
|
||||
targetStatus = overId as RoadmapFeatureStatus;
|
||||
} else {
|
||||
// Dropped on a feature - get its phase and position
|
||||
// Dropped on a feature - get its status
|
||||
const overFeature = roadmap.features.find((f) => f.id === overId);
|
||||
if (!overFeature) return;
|
||||
targetPhaseId = overFeature.phaseId;
|
||||
const targetFeatures = featuresByPhase[targetPhaseId] || [];
|
||||
targetFeatureIndex = targetFeatures.findIndex((f) => f.id === overId);
|
||||
targetStatus = overFeature.status;
|
||||
}
|
||||
|
||||
const sourcePhaseId = draggedFeature.phaseId;
|
||||
const sourceStatus = draggedFeature.status;
|
||||
|
||||
if (sourcePhaseId !== targetPhaseId) {
|
||||
// Moving to a different phase
|
||||
updateFeaturePhase(activeFeatureId, targetPhaseId);
|
||||
|
||||
// If dropped on a specific feature, reorder within the new phase
|
||||
if (targetFeatureIndex !== -1) {
|
||||
const targetFeatures = [...(featuresByPhase[targetPhaseId] || [])];
|
||||
// Add the moved feature at the target position
|
||||
const updatedIds = targetFeatures.map((f) => f.id);
|
||||
if (!updatedIds.includes(activeFeatureId)) {
|
||||
updatedIds.splice(targetFeatureIndex, 0, activeFeatureId);
|
||||
reorderFeatures(targetPhaseId, updatedIds);
|
||||
}
|
||||
}
|
||||
if (sourceStatus !== targetStatus) {
|
||||
// Moving to a different status
|
||||
updateFeatureStatus(activeFeatureId, targetStatus);
|
||||
|
||||
// Trigger save callback
|
||||
onSave?.();
|
||||
} else {
|
||||
// Reordering within the same phase
|
||||
const sourceFeatures = featuresByPhase[sourcePhaseId] || [];
|
||||
const oldIndex = sourceFeatures.findIndex((f) => f.id === activeFeatureId);
|
||||
const newIndex = targetFeatureIndex !== -1 ? targetFeatureIndex : sourceFeatures.length - 1;
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
const reorderedIds = arrayMove(
|
||||
sourceFeatures.map((f) => f.id),
|
||||
oldIndex,
|
||||
newIndex
|
||||
);
|
||||
reorderFeatures(sourcePhaseId, reorderedIds);
|
||||
|
||||
// Trigger save callback
|
||||
onSave?.();
|
||||
}
|
||||
}
|
||||
// Note: We don't support reordering within status columns for now
|
||||
// Features are displayed in their natural order within each status
|
||||
};
|
||||
|
||||
// Get status label for a feature (for display in drag overlay)
|
||||
const getStatusLabelForFeature = (feature: RoadmapFeature) => {
|
||||
const statusColumn = ROADMAP_STATUS_COLUMNS.find((c) => c.id === feature.status);
|
||||
return statusColumn?.label || 'Unknown Status';
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -311,19 +292,18 @@ export function RoadmapKanbanView({
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 gap-4 overflow-x-auto p-6">
|
||||
{roadmap.phases
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((phase) => (
|
||||
<DroppablePhaseColumn
|
||||
key={phase.id}
|
||||
phase={phase}
|
||||
features={featuresByPhase[phase.id] || []}
|
||||
onFeatureClick={onFeatureClick}
|
||||
onConvertToSpec={onConvertToSpec}
|
||||
onGoToTask={onGoToTask}
|
||||
isOver={overColumnId === phase.id}
|
||||
/>
|
||||
))}
|
||||
{ROADMAP_STATUS_COLUMNS.map((column) => (
|
||||
<DroppableStatusColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
features={featuresByStatus[column.id] || []}
|
||||
roadmap={roadmap}
|
||||
onFeatureClick={onFeatureClick}
|
||||
onConvertToSpec={onConvertToSpec}
|
||||
onGoToTask={onGoToTask}
|
||||
isOver={overColumnId === column.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag overlay - enhanced visual feedback */}
|
||||
@@ -331,6 +311,11 @@ export function RoadmapKanbanView({
|
||||
{activeFeature ? (
|
||||
<div className="drag-overlay-card">
|
||||
<Card className="p-4 w-80 shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{getStatusLabelForFeature(activeFeature)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="font-medium">{activeFeature.title}</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
{activeFeature.description}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user