fix: 2.7.2 bug fixes and improvements (#388)
* fix(frontend): prevent false stuck detection for ai_review tasks Tasks in ai_review status were incorrectly showing "Task Appears Stuck" in the detail modal. This happened because the isRunning check included ai_review status, triggering stuck detection when no process was found. However, ai_review means "all subtasks completed, awaiting QA" - no build process is expected to be running. This aligns the detail modal logic with TaskCard which correctly only checks for in_progress status. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci(beta-release): use tag-based versioning instead of modifying package.json Previously the beta-release workflow committed version changes to package.json on the develop branch, which caused two issues: 1. Permission errors (github-actions[bot] denied push access) 2. Beta versions polluted develop, making merges to main unclean Now the workflow creates only a git tag and injects the version at build time using electron-builder's --config.extraMetadata.version flag. This keeps package.json at the next stable version and avoids any commits to develop. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ollama): add packaged app path resolution for Ollama detector script The Ollama detection was failing in packaged builds because the Python script path resolution only checked development paths. In packaged apps, __dirname points to the app bundle, and the relative path "../../../backend" doesn't resolve correctly. Added process.resourcesPath for packaged builds (checked first via app.isPackaged) which correctly locates the backend scripts in the Resources folder. Also added DEBUG-only logging to help troubleshoot script location issues. Closes #129 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(paths): remove legacy auto-claude path fallbacks Replace all legacy 'auto-claude/' source path detection with 'apps/backend'. Services now validate paths using runners/spec_runner.py as the marker instead of requirements.txt, ensuring only valid backend directories match. - Remove legacy fallback paths from all getAutoBuildSourcePath() implementations - Add startup validation in index.ts to skip invalid saved paths - Update project-initializer to detect apps/backend for local dev projects - Standardize path detection across all services 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(frontend): address PR review feedback from Auto Claude and bots Fixes from PR #300 reviews: CRITICAL: - path-resolver.ts: Update marker from requirements.txt to runners/spec_runner.py for consistent backend detection across all files HIGH: - useTaskDetail.ts: Restore stuck task detection for ai_review status (CHANGELOG documents this feature) - TerminalGrid.tsx: Include legacy terminals without projectPath (prevents hiding terminals after upgrade) - memory-handlers.ts: Add packaged app path in OLLAMA_PULL_MODEL handler (fixes production builds) MEDIUM: - OAuthStep.tsx: Stricter profile slug sanitization (only allow alphanumeric and dashes) - project-store.ts: Fix regex to not truncate at # in code blocks (uses \n#{1,6}\s to match valid markdown headings only) - memory-service.ts: Add backend structure validation with spec_runner.py marker - subprocess-spawn.test.ts: Update test to use new marker pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(frontend): validate empty profile slug after sanitization Add validation to prevent empty config directory path when profile name contains only special characters (e.g., "!!!"). Shows user-friendly error message requiring at least one letter or number. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-2 - Verify macOS build process bundles all dependencies Updated checkDepsInstalled() to verify BOTH claude_agent_sdk AND dotenv are importable. Previously, only claude_agent_sdk was checked, which could cause the app to skip reinstalling dependencies if some packages were missing (like python-dotenv). Fixes: #359 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add z-10 to dialog close button to fix click handling (#379) The close button in DialogContent was missing z-index, causing it to be covered by content elements with relative positioning or overflow properties. This prevented clicks from reaching the button in modals like TaskCreationWizard. Added z-10 to match the pattern used in FullScreenDialogContent. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): show add project modal instead of opening file explorer directly The "+" button in the project tab bar was bypassing the AddProjectModal and directly opening a file explorer. Now it correctly shows the modal which gives users the choice between "Open Existing Project" and "Create New Project" with the full creation form flow. * feat(agent): add project setting to include CLAUDE.md in agent context Agents now read the project's CLAUDE.md file and include its instructions in the system prompt. This allows per-project customization of agent behavior. - Add useClaudeMd toggle to ProjectSettings (default: ON) - Pass USE_CLAUDE_MD env var from frontend to backend - Backend loads CLAUDE.md content when setting is enabled - Add i18n translations for EN and FR * refactor(python-env-manager): enhance dependency checks in checkDepsInstalled() Updated the checkDepsInstalled() method to verify all necessary dependencies for the backend, including claude_agent_sdk, dotenv, google.generativeai, and optional Graphiti dependencies for Python 3.12+. This change ensures users have all required packages installed, preventing broken functionality. Increased timeout for dependency checks to improve reliability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(terminal): allow project switching shortcuts when terminal focused xterm.js was capturing all keyboard events when a terminal had focus, preventing Cmd/Ctrl+1-9 and Cmd/Ctrl+Tab shortcuts from reaching the window-level handlers in ProjectTabBar. Uses attachCustomKeyEventHandler to let these specific key combinations bubble up to the global handlers for project tab switching. * feat(deps): bundle Python packages at build time for instant app launch Eliminates runtime pip install failures that were causing user adoption issues. Python dependencies are now installed during build and bundled with the app. Changes: - Extended download-python.cjs to install packages and strip unnecessary files - Added site-packages to electron-builder extraResources - Updated PythonEnvManager to detect and use bundled packages via PYTHONPATH - Updated all spawn calls in agent files to include pythonEnv - Added python-runtime/** to eslint ignores The app now starts instantly without requiring pip install on first launch. Dev mode continues to work with venv-based setup as before. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ui): add responsive terminal title width based on terminal count Terminal names now dynamically adjust their max-width based on how many terminals are displayed. With fewer terminals, titles can be wider (up to 256px with 1-2 terminals), and with more terminals they become narrower (down to 96px with 10-12 terminals) to ensure all header elements fit properly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): remove broken terminal buttons from task review The "Open Terminal" and "Open Project in Terminal" buttons in WorkspaceStatus and StagedSuccessMessage were creating PTY processes in the backend but not updating the Zustand store, causing terminals to not appear in the TerminalGrid UI. Instead of fixing the sync issue, removed the buttons entirely since users should use their preferred IDE or terminal application. Closes #99 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ollama): add qwen3 embedding models with global download progress Add qwen3-embedding:4b (recommended/balanced), :8b (highest quality), and :0.6b (fastest) as new local embedding model options in both backend and frontend. Models display with visible badges indicating their purpose. Key changes: - Use Ollama HTTP API for downloads with proper NDJSON progress streaming - Create global download store (Zustand) to track downloads across app - Add floating GlobalDownloadIndicator that persists when navigating away - Fix model installation detection to match exact version tags (not base name) - Add indeterminate progress bar animation while waiting for events 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(startup): auto-migrate stale autoBuildPath from old project structure When the project moved from /auto-claude to /apps/backend structure, some developers' settings files retained the old path causing startup warnings. The app now auto-detects this pattern and migrates the setting on startup, saving the corrected path back to settings.json. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(agents): add phase-aware MCP server configuration and MCP Overview UI Implements phase-aware tool and MCP server configuration to reduce context window bloat and improve agent startup performance. Each agent phase now gets only the MCP servers and tools it needs. Key changes: - Add AGENT_CONFIGS registry in models.py as single source of truth - Add simple_client.py factory for utility operations (commit, merge, etc.) - Migrate 11 direct SDK clients to use factory pattern - Add get_required_mcp_servers() for dynamic server selection - Add Flutter/Dart support to command registry - Add MCP Overview sidebar tab showing servers/tools per agent phase - Fix followup_reviewer to use user's thinking level settings - Consolidate THINKING_BUDGET_MAP to phase_config.py (remove duplicate) MCP servers are now loaded conditionally: - Spec phases: minimal (no MCP for most) - Build phases: context7 + graphiti + auto-claude - QA phases: + electron OR puppeteer (based on project type) - Utility phases: minimal or none 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(devtools): comprehensive IDE/terminal detection and configuration Expand SupportedIDE type from 7 to 62+ options covering VS Code ecosystem, AI-powered editors (Cursor, Windsurf, Zed, PearAI, Kiro), JetBrains suite, classic editors (Vim, Neovim, Emacs), platform-specific IDEs, and cloud IDEs. Expand SupportedTerminal type from 7 to 37+ options including GPU-accelerated terminals (Alacritty, Kitty, WezTerm), macOS/Windows/Linux native terminals, and modern options (Warp, Ghostty, Rio). Add smart platform-native detection: - macOS: Uses Spotlight (mdfind) for fast app discovery - Windows: Queries registry via PowerShell - Linux: Parses .desktop files from standard locations UI improvements: - Add DevToolsStep to onboarding wizard for initial configuration - Add DevToolsSettings component for settings page - Alphabetically sort IDE/terminal dropdowns for easy scanning - Show detection status (checkmark) for installed tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(github): expand AI bot detection patterns for PR reviews The PR review was only detecting 1 bot comment when there were actually 8 (CodeRabbit + GitHub Advanced Security). Expanded AI_BOT_PATTERNS from 22 to 62 patterns covering: - AI Code Review: Greptile, Sourcery, Qodo variants - AI Assistants: Copilot SWE Agent, Sweep AI, Bito, Codeium, Devin - GitHub Native: Dependabot, Merge Queue, Advanced Security - Code Quality: DeepSource, CodeClimate, CodeFactor, Codacy - Security: Snyk, GitGuardian, Semgrep - Coverage: Codecov, Coveralls - Automation: Renovate, Mergify, Imgbot, Allstar, Percy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(security): address PR review security issues and code quality Fixes multiple security vulnerabilities and code quality issues identified in PR #388 code review: Security fixes: - Fix command injection in Terminal.app/iTerm2 AppleScript by escaping paths - Fix command injection in Windows cmd.exe terminal launch using spawn() - Fix command injection in Linux xterm fallback with proper escaping - Fix command injection in custom IDE/terminal paths using execFileAsync() - Add path traversal validation after environment variable expansion - Fix file system race condition in settings migration by re-reading file - Use SystemRoot env var for Windows tar path instead of hardcoded C:\Windows Code quality fixes: - Remove unused electron_mcp_enabled variable in client.py - Remove unused json and timezone imports in test_ollama_embedding_memory.py - Remove unused useTranslation import and t variable in AgentTools.tsx - Fix Windows process kill to use platform-specific termination - Add 10-second timeout to macOS Spotlight app detection - Dynamically detect Python version in venv instead of hardcoding 3.12 - Fix README download links (version was repeated 3 times) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(reliability): add error recovery for file writes and process tracking Addresses remaining medium-priority issues from PR #388 review: 1. Background file writes (worktree-handlers.ts): - Add retry logic with exponential backoff (3 attempts) - Add write verification by reading back the file - Log warnings if main plan write fails after retries 2. Venv process tracking (python-env-manager.ts): - Track spawned processes in activeProcesses Set - Add 2-minute timeout for hung venv creation - Add cleanup() method to kill orphaned processes - Register cleanup on app 'will-quit' event 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cleanup): remove unused function and fix race condition - Remove unused escapeWindowsCmdPath function (replaced by spawn with args) - Fix race condition in settings migration by removing existsSync check and catching read errors atomically 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): guard app.on for test environments The app.on('will-quit') handler fails in test environments where the Electron app module is mocked without the 'on' method. Add a guard to check if app.on is a function before calling it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(codeql): address remaining security alerts and code quality issues Fixes 4 CodeQL alerts from PR #388: 1. Clear-text logging (HIGH): Change "password hashing" to "credential hashing" in test discovery dict to avoid false positive 2. File system race condition (HIGH): Simplify settings migration in index.ts to use existing settings object instead of re-reading file (TOCTOU fix) 3. File system race condition (HIGH): Use EAFP pattern in worktree-handlers.ts - Remove existsSync check before read/write - Handle ENOENT in catch block instead 4. Unused import (NOTE): Use importlib.util.find_spec() instead of try/import to check claude_agent_sdk availability in batch_validator.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): run tests on all Python versions, not just 3.13 The `Run tests` step had `if: matrix.python-version != '3.12'` which skipped regular test execution for Python 3.12. Now tests run on both 3.12 and 3.13, with coverage reporting still only on 3.12. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(codeql): address remaining false positives with proper patterns 1. test_ollama_embedding_memory.py: Add lgtm suppression for test query string containing "authentication" - not actual credentials 2. index.ts: Remove existsSync check, use EAFP pattern (try/catch with ENOENT handling) to eliminate TOCTOU between file existence check and read/write operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): add sleep to cache invalidation test for CI stability The test_cache_invalidation_on_file_creation test was failing on Python 3.13 in CI due to file system timing issues. Adding a small delay after file creation ensures the mtime change is detected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(codeql): avoid authentication keyword in test query string CodeQL flags "authentication" as sensitive data. Changed to "auth" to avoid the false positive while preserving test functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(codeql): remove all auth-related terms from test data CodeQL was flagging OAuth/JWT/token terms as sensitive data being logged. Changed test data to use neutral terms like API, middleware, notifications while preserving the test's semantic search functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(github): fetch PR reviews in followup review to capture Cursor/CodeRabbit feedback Follow-up reviews were missing reviews from AI tools like Cursor and CodeRabbit because the code only fetched comments (inline + issue), not formal PR reviews. GitHub distinguishes between: - Reviews: formal submissions via /pulls/{pr}/reviews endpoint - Review comments: inline comments on files - Issue comments: general PR discussion Added get_reviews_since() to fetch formal reviews, updated FollowupContextGatherer to include them, and added a dedicated section in the AI prompt so the followup reviewer considers findings from other AI tools. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(github): handle timezone-naive datetime in get_reviews_since The reviewed_at timestamp can be offset-naive while GitHub API returns offset-aware timestamps, causing comparison to fail with: "can't compare offset-naive and offset-aware datetimes" Added explicit timezone handling to ensure both timestamps are timezone-aware (defaulting to UTC) before comparison. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(release): separate stable and beta download sections in README The previous regex patterns in the release workflow only matched stable versions (X.Y.Z) and failed to update README for beta releases like 2.7.2-beta.10. This caused stale version information in download links. Changes: - Split README download section into Stable Release and Beta Release - Added HTML comment markers for reliable section targeting - Replaced fragile sed commands with Python script for cross-platform regex - Workflow now detects release type and updates only the appropriate section - Fixed semver pattern to require dot in prerelease (beta.10) to avoid matching platform suffixes (win32, darwin) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(review): address Cursor and CodeRabbit review feedback Fixes from code reviews: **Cursor (HIGH priority):** - Remove write permissions from qa_reviewer agent - reviewers should only read code and run tests, not modify files. qa_fixer still has write access. **Cursor (MEDIUM priority):** - Add model fallback default in orchestrator_reviewer.py to match followup_reviewer.py pattern **CodeRabbit:** - Add missing shelf and aqueduct framework entries to FRAMEWORK_COMMANDS - Add i18n translations for GlobalDownloadIndicator (downloads section) - Fix accessibility: convert clickable div to button with proper aria attrs - Add SafeLink component for ReactMarkdown to prevent phishing attacks via malicious links in AI-generated content Also updates test to verify qa_reviewer is read-only (plus Bash). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tools): only allow auto-claude tools when MCP server is available Previously, auto-claude tools were added to the allowed tools list unconditionally based on agent config, even if the SDK wasn't available or the MCP server wasn't running. This could cause confusing errors. Now auto-claude tools are only added when: 1. The agent requires "auto-claude" in its mcp_servers 2. is_tools_available() returns True (SDK is available) This ensures tools and MCP servers are always in sync. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cross-platform): add Windows support for Python paths and CLI detection This fixes several cross-platform compatibility issues that broke the app on Windows: **subprocess-runner.ts:** - getPythonPath() now returns Scripts/python.exe on Windows, bin/python on Unix - validateGitHubModule() now uses `where gh` on Windows instead of `which gh` - Added platform-specific install instructions (winget/brew/URL) - venvPath check now uses getPythonPath() instead of hardcoded Unix path **python-env-manager.ts:** - Fixed Windows site-packages path (Lib/site-packages, not Lib/python3.x/site-packages) - Windows venv structure doesn't have python version subfolder **generator.ts:** - Fixed PATH split to use path.delimiter instead of hardcoded ':' These issues were causing GitHub automation, Python environment detection, and dependency loading to fail on Windows systems. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): increase sleep time for reliable mtime detection on CI The cache invalidation tests were failing intermittently on CI with Python 3.13 because some filesystems have 1-second mtime resolution. The previous 0.1s sleep was not sufficient to guarantee a different mtime between file writes. Changes: - Increase sleep from 0.1s to 1.0s in both cache invalidation tests - Compute hash before first call to ensure consistency - Add clearer comments explaining the timing requirements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ci): replace DCO with one-time CLA for contributions Migrates from per-commit DCO sign-off to a one-time Contributor License Agreement (CLA) using CLA Assistant GitHub Action. Why: - DCO required sign-off on every commit, causing 99% of PRs to fail checks - CLA is one-time: contributors sign once and it applies to all future PRs - CLA grants licensing flexibility for potential future enterprise options while keeping the project open source under AGPL-3.0 - Contributors retain full copyright ownership of their contributions Changes: - Add CLA.md (Apache ICLA-style agreement) - Add .github/workflows/cla.yml (CLA enforcement via GitHub Action) - Update pr-status-gate.yml to require CLA check instead of DCO - Update CONTRIBUTING.md with CLA signing instructions - Remove .github/workflows/quality-dco.yml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(worktree): respect task-level branch override when creating worktrees The execution handlers were only reading `project.settings.mainBranch` for determining which branch to create worktrees from, ignoring the task-level override stored in `task.metadata.baseBranch`. This fix ensures the branch selection priority is: 1. Task-level override (task.metadata.baseBranch) - if user selected a specific branch for this task in the Git Options 2. Project default (project.settings.mainBranch) - fallback to project settings if no task-level override Fixed in three code paths: - TASK_START handler (line 121) - TASK_UPDATE_STATUS auto-start logic (line 488) - TASK_RECOVER_STUCK auto-restart logic (line 765) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(debug): add always-on logging for production builds Implement persistent application logging using electron-log that works in packaged builds (DMG, EXE, AppImage), not just development mode. This allows users to capture bugs on first occurrence without needing to reproduce issues with debug mode enabled. Features: - Always-on file logging (10MB max, auto-rotation) - Enhanced logging for beta/alpha/rc versions - Debug settings UI with "Open Logs Folder" and "Copy Debug Info" - System info collection for bug reports - Cross-platform log paths (macOS, Windows, Linux) - Comprehensive test suite (29 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(debug): prevent duplicate log initialization and remove unused imports - Wrap log.initialize() in try-catch to handle re-import scenarios in tests - Remove unused 'mkdtempSync' import from test file - Remove unused 'logger' import from index.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): mock electron-log in ipc-handlers tests for CI The ipc-handlers tests were failing in CI because importing debug-handlers now pulls in app-logger which uses electron-log/main. On CI, Electron isn't installed correctly, causing the tests to fail with "Electron failed to install correctly". Added electron-log/main mock to ipc-handlers.test.ts to prevent the dependency on the Electron binary. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tests): use secure temp directories in app-logger tests Replace predictable temp file paths with mkdtempSync() to address CodeQL "Insecure temporary file" security alerts. Fixed paths in the OS temp directory are vulnerable to symlink attacks; using random suffixes prevents this attack vector. * fix(hooks): align commit validation and version sync with CI workflows - Update commit-msg pattern to match GitHub workflow: support mixed case, underscores, slashes, dots in scope and ! for breaking changes - Add shields.io hyphen escaping (- → --) for version badges in pre-commit - Fix version regex to match both stable and prerelease versions (X.Y.Z-beta.N) These inconsistencies caused local commits to fail validation that would pass CI, and version badges to break when bumping to prerelease versions. * fix(agent-queue): prevent race condition when switching between ideation and roadmap Remove redundant deleteProcess() calls from the "intentionally stopped" exit handler branches. When starting ideation while roadmap is running (or vice versa), the old process is killed and killProcess() already removes it from state. However, the async exit handler was also calling deleteProcess(projectId), which would delete the NEW process that had been added with the same projectId. This caused "No project path available to load session" errors because the ideation process info was deleted before its completion handler ran. * fix(followup-review): prevent duplicate contributor reviews in prompt The pr_reviews_since_review field was incorrectly set to all PR reviews instead of only AI reviews. This caused contributor reviews to appear twice in the followup review prompt - once in contributor_comments and again in pr_reviews. Per the model docstring and prompt section, this field is meant for AI tool reviews (Cursor, CodeRabbit, etc.) only. Also adds structured_output attribute handling for SDK validated JSON responses in the followup reviewer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(codeql): resolve TOCTOU race condition and memory leak Replace existsSync() checks with EAFP pattern (try/catch with accessSync) in index.ts to prevent time-of-check to time-of-use race conditions during autoBuildPath migration. Add cleanupProgressTracker() to download-store.ts and call it from completeDownload, failDownload, and clearDownload actions to prevent memory leaks from progressTracker accumulating entries indefinitely. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
This commit is contained in:
@@ -44,7 +44,6 @@ jobs:
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.python-version != '3.12'
|
||||
working-directory: apps/backend
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: "CLA Assistant"
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
CLAAssistant:
|
||||
name: CLA Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: |
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' ||
|
||||
github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: '.github/cla-signatures.json'
|
||||
path-to-document: 'https://github.com/AndyMik90/Auto-Claude/blob/main/CLA.md'
|
||||
branch: 'main'
|
||||
# Allowlist for bots and automation
|
||||
allowlist: 'dependabot[bot],github-actions[bot],renovate[bot],coderabbitai[bot]'
|
||||
# Custom messages
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can accept your PR, you need to sign our Contributor License Agreement (CLA).
|
||||
|
||||
**To sign the CLA**, please comment on this PR with exactly:
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
|
||||
You can read the full CLA here: [CLA.md](https://github.com/AndyMik90/Auto-Claude/blob/main/CLA.md)
|
||||
|
||||
---
|
||||
**Why do we need a CLA?**
|
||||
|
||||
Auto Claude is licensed under AGPL-3.0. The CLA ensures the project has proper licensing flexibility should we introduce additional licensing options in the future.
|
||||
|
||||
You retain full copyright ownership of your contributions.
|
||||
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
|
||||
custom-allsigned-prcomment: |
|
||||
All contributors have signed the CLA. Thank you!
|
||||
lock-pullrequest-aftermerge: false
|
||||
suggest-recheck: true
|
||||
@@ -2,7 +2,7 @@ name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security, Quality DCO, Quality Commit Lint]
|
||||
workflows: [CI, Lint, Quality Security, CLA Assistant, Quality Commit Lint]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
@@ -50,8 +50,8 @@ jobs:
|
||||
'Quality Security / CodeQL (python) (pull_request)',
|
||||
'Quality Security / Python Security (Bandit) (pull_request)',
|
||||
'Quality Security / Security Summary (pull_request)',
|
||||
// Quality DCO workflow (quality-dco.yml) - 1 check
|
||||
'Quality DCO / DCO Check (pull_request)',
|
||||
// CLA Assistant workflow (cla.yml) - 1 check
|
||||
'CLA Assistant / CLA Check',
|
||||
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
|
||||
'Quality Commit Lint / Conventional Commits (pull_request)'
|
||||
];
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
name: Quality DCO
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
# Comma-separated list of emails that should be ignored during DCO checks
|
||||
DCO_EXCLUDE_EMAILS: ''
|
||||
|
||||
# Job is taken from https://github.com/cncf/dcochecker/blob/main/action.yml
|
||||
# Using steps instead of reusable workflow to preserve custom failure message
|
||||
jobs:
|
||||
check:
|
||||
name: DCO Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.x
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Check DCO
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DCO_CHECK_VERBOSE: '1'
|
||||
DCO_CHECK_EXCLUDE_EMAILS: ${{ env.DCO_EXCLUDE_EMAILS }}
|
||||
run: |
|
||||
pip3 install -U dco-check
|
||||
dco-check
|
||||
|
||||
- name: DCO Help on Failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const helpMsg = `## ❌ DCO Sign-off Required
|
||||
|
||||
This project requires all commits to be signed off with the Developer Certificate of Origin (DCO).
|
||||
|
||||
### How to Fix
|
||||
|
||||
**⚠️ Important:** If you merged \`develop\` or another branch, be careful not to accidentally sign other people's commits. Only sign YOUR commits.
|
||||
|
||||
**Step 1: Identify which commits are yours**
|
||||
\`\`\`bash
|
||||
git log --oneline
|
||||
\`\`\`
|
||||
|
||||
**Step 2: Choose the appropriate method**
|
||||
|
||||
**If only your last commit needs signing:**
|
||||
\`\`\`bash
|
||||
git commit --amend --signoff
|
||||
git push --force-with-lease
|
||||
\`\`\`
|
||||
|
||||
**If you have multiple commits and HAVE NOT merged other branches:**
|
||||
\`\`\`bash
|
||||
git rebase HEAD~N --signoff # Replace N with number of YOUR commits
|
||||
git push --force-with-lease
|
||||
\`\`\`
|
||||
|
||||
**If you merged another branch (use interactive rebase):**
|
||||
\`\`\`bash
|
||||
git rebase -i HEAD~N # Replace N with total number of commits
|
||||
# Mark only YOUR commits with 'edit', leave others as 'pick'
|
||||
# For each commit you marked:
|
||||
git commit --amend --signoff --no-edit
|
||||
git rebase --continue
|
||||
git push --force-with-lease
|
||||
\`\`\`
|
||||
|
||||
**Tip: Configure git to always sign off future commits**
|
||||
\`\`\`bash
|
||||
git config --global format.signoff true
|
||||
\`\`\`
|
||||
|
||||
### What is DCO?
|
||||
|
||||
The [Developer Certificate of Origin](https://developercertificate.org/) is a lightweight way for contributors to certify that they wrote or have the right to submit the code they are contributing.
|
||||
|
||||
By signing off, you agree to the DCO terms:
|
||||
- The contribution was created by you
|
||||
- You have the right to submit it under the project's license
|
||||
- You understand the contribution is public and recorded
|
||||
|
||||
### Sign-off Format
|
||||
|
||||
Your commit message should end with:
|
||||
\`\`\`
|
||||
Signed-off-by: Your Name <your.email@example.com>
|
||||
\`\`\`
|
||||
|
||||
This line is automatically added when you use \`git commit -s\` or \`git commit --signoff\`.
|
||||
`;
|
||||
|
||||
core.summary.addRaw(helpMsg);
|
||||
await core.summary.write();
|
||||
@@ -497,26 +497,116 @@ jobs:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
- name: Extract version and detect release type
|
||||
id: version
|
||||
run: |
|
||||
# Extract version from tag (v2.7.2 -> 2.7.2)
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Updating README to version: $VERSION"
|
||||
|
||||
# Detect if this is a prerelease (contains - after version, e.g., 2.7.2-beta.10)
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
echo "Detected PRERELEASE: $VERSION"
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
echo "Detected STABLE release: $VERSION"
|
||||
fi
|
||||
|
||||
- name: Update README.md
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
python3 << 'EOF'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Update version badge: version-X.Y.Z-blue
|
||||
sed -i "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-${VERSION}-blue/g" README.md
|
||||
version = "${{ steps.version.outputs.version }}"
|
||||
is_prerelease = "${{ steps.version.outputs.is_prerelease }}" == "true"
|
||||
|
||||
# Update download links: Auto-Claude-X.Y.Z
|
||||
sed -i "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-${VERSION}/g" README.md
|
||||
# Shields.io escapes hyphens as --
|
||||
version_badge = version.replace("-", "--")
|
||||
|
||||
echo "README.md updated to version $VERSION"
|
||||
grep -E "(version-|Auto-Claude-)" README.md | head -10
|
||||
# Read README
|
||||
with open("README.md", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10)
|
||||
# Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin)
|
||||
semver = r'\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
|
||||
# Shields.io escaped pattern (hyphens as --)
|
||||
semver_badge = r'\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
|
||||
|
||||
def update_section(text, start_marker, end_marker, replacements):
|
||||
"""Update content between markers with given replacements."""
|
||||
pattern = f'({re.escape(start_marker)})(.*?)({re.escape(end_marker)})'
|
||||
def replace_section(match):
|
||||
section = match.group(2)
|
||||
for old_pattern, new_value in replacements:
|
||||
section = re.sub(old_pattern, new_value, section)
|
||||
return match.group(1) + section + match.group(3)
|
||||
return re.sub(pattern, replace_section, text, flags=re.DOTALL)
|
||||
|
||||
if is_prerelease:
|
||||
print(f"Updating BETA section to {version} (badge: {version_badge})")
|
||||
|
||||
# Update beta badge
|
||||
content = re.sub(
|
||||
rf'beta-{semver_badge}-orange',
|
||||
f'beta-{version_badge}-orange',
|
||||
content
|
||||
)
|
||||
|
||||
# Update beta version badge link
|
||||
content = update_section(content,
|
||||
'<!-- BETA_VERSION_BADGE -->', '<!-- BETA_VERSION_BADGE_END -->',
|
||||
[(rf'tag/v{semver}\)', f'tag/v{version})')])
|
||||
|
||||
# Update beta downloads
|
||||
content = update_section(content,
|
||||
'<!-- BETA_DOWNLOADS -->', '<!-- BETA_DOWNLOADS_END -->',
|
||||
[
|
||||
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
|
||||
(rf'download/v{semver}/', f'download/v{version}/'),
|
||||
])
|
||||
else:
|
||||
print(f"Updating STABLE section to {version} (badge: {version_badge})")
|
||||
|
||||
# Update top version badge
|
||||
content = update_section(content,
|
||||
'<!-- TOP_VERSION_BADGE -->', '<!-- TOP_VERSION_BADGE_END -->',
|
||||
[
|
||||
(rf'version-{semver_badge}-blue', f'version-{version_badge}-blue'),
|
||||
(rf'tag/v{semver}\)', f'tag/v{version})'),
|
||||
])
|
||||
|
||||
# Update stable badge
|
||||
content = re.sub(
|
||||
rf'stable-{semver_badge}-blue',
|
||||
f'stable-{version_badge}-blue',
|
||||
content
|
||||
)
|
||||
|
||||
# Update stable version badge link
|
||||
content = update_section(content,
|
||||
'<!-- STABLE_VERSION_BADGE -->', '<!-- STABLE_VERSION_BADGE_END -->',
|
||||
[(rf'tag/v{semver}\)', f'tag/v{version})')])
|
||||
|
||||
# Update stable downloads
|
||||
content = update_section(content,
|
||||
'<!-- STABLE_DOWNLOADS -->', '<!-- STABLE_DOWNLOADS_END -->',
|
||||
[
|
||||
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
|
||||
(rf'download/v{semver}/', f'download/v{version}/'),
|
||||
])
|
||||
|
||||
# Write updated README
|
||||
with open("README.md", "w") as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"README.md updated for {version} (prerelease={is_prerelease})")
|
||||
EOF
|
||||
|
||||
echo "--- Verifying update ---"
|
||||
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
|
||||
|
||||
- name: Commit and push README update
|
||||
run: |
|
||||
|
||||
+12
-4
@@ -1,12 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Commit message validation
|
||||
# Enforces conventional commit format: type(scope): description
|
||||
# Enforces conventional commit format: type(scope)!?: description
|
||||
#
|
||||
# Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots
|
||||
# Optional ! for breaking changes
|
||||
# Examples:
|
||||
# feat(tasks): add drag and drop support
|
||||
# fix(terminal): resolve scroll position issue
|
||||
# feat!: breaking change without scope
|
||||
# feat(api)!: breaking change with scope
|
||||
# docs: update README with setup instructions
|
||||
# chore: update dependencies
|
||||
|
||||
@@ -14,8 +18,10 @@ commit_msg_file=$1
|
||||
commit_msg=$(cat "$commit_msg_file")
|
||||
|
||||
# Regex for conventional commits
|
||||
# Format: type(optional-scope): description
|
||||
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?: .{1,100}$"
|
||||
# Format: type(optional-scope)!?: description
|
||||
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots (consistent with GitHub workflow)
|
||||
# Optional ! for breaking changes: feat!: or feat(scope)!:
|
||||
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_/.-]+\))?!?: .{1,100}$"
|
||||
|
||||
# Allow merge commits
|
||||
if echo "$commit_msg" | grep -qE "^Merge "; then
|
||||
@@ -36,7 +42,7 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
|
||||
echo ""
|
||||
echo "Your message: $first_line"
|
||||
echo ""
|
||||
echo "Expected format: type(scope): description"
|
||||
echo "Expected format: type(scope)!?: description"
|
||||
echo ""
|
||||
echo "Valid types:"
|
||||
echo " feat - A new feature"
|
||||
@@ -54,6 +60,8 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
|
||||
echo "Examples:"
|
||||
echo " feat(tasks): add drag and drop support"
|
||||
echo " fix(terminal): resolve scroll position issue"
|
||||
echo " feat!: breaking change without scope"
|
||||
echo " feat(api)!: breaking change with scope"
|
||||
echo " docs: update README"
|
||||
echo " chore: update dependencies"
|
||||
echo ""
|
||||
|
||||
+6
-4
@@ -38,10 +38,12 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
|
||||
# Sync to README.md
|
||||
if [ -f "README.md" ]; then
|
||||
# Update version badge
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md
|
||||
# Update download links
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md
|
||||
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
# Update version badge - match both stable (X.Y.Z) and prerelease (X.Y.Z-prerelease.N or X.Y.Z--prerelease.N)
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" README.md
|
||||
# Update download links - match both stable and prerelease versions
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*\(-[a-z]*\.[0-9]*\)*/Auto-Claude-$VERSION/g" README.md
|
||||
rm -f README.md.bak
|
||||
git add README.md
|
||||
echo " Updated README.md to $VERSION"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Auto Claude Individual Contributor License Agreement
|
||||
|
||||
Thank you for your interest in contributing to Auto Claude. This Contributor License Agreement ("Agreement") documents the rights granted by contributors to the Project.
|
||||
|
||||
By signing this Agreement, you accept and agree to the following terms and conditions for your present and future Contributions submitted to the Project.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
**"You" (or "Your")** means the individual who submits a Contribution to the Project.
|
||||
|
||||
**"Contribution"** means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, the Project. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Project or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of discussing and improving the Project.
|
||||
|
||||
**"Project"** means Auto Claude, a multi-agent autonomous coding framework, currently available at https://github.com/AndyMik90/Auto-Claude.
|
||||
|
||||
**"Project Owner"** means Andre Mikalsen and any designated successors or assignees.
|
||||
|
||||
## 2. Grant of Copyright License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to:
|
||||
|
||||
- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute Your Contributions and such derivative works
|
||||
- Sublicense any or all of the foregoing rights to third parties
|
||||
|
||||
## 3. Grant of Patent License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Project to which such Contribution(s) was submitted.
|
||||
|
||||
## 4. Future Licensing Flexibility
|
||||
|
||||
You understand and agree that the Project Owner may, in the future, license the Project, including Your Contributions, under additional licenses beyond the current GNU Affero General Public License version 3.0 (AGPL-3.0). Such additional licenses may include commercial or enterprise licenses.
|
||||
|
||||
This provision ensures the Project has proper licensing flexibility should such licensing options be introduced in the future. The open source version of the Project will continue to be available under AGPL-3.0.
|
||||
|
||||
## 5. Representations
|
||||
|
||||
You represent that:
|
||||
|
||||
(a) You are legally entitled to grant the above licenses. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, or that your employer has waived such rights for your Contributions to the Project.
|
||||
|
||||
(b) Each of Your Contributions is Your original creation. You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
(c) Your Contribution does not violate any third-party rights, including but not limited to intellectual property rights, privacy rights, or contractual obligations.
|
||||
|
||||
## 6. Support and Warranty Disclaimer
|
||||
|
||||
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all.
|
||||
|
||||
UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, YOU PROVIDE YOUR CONTRIBUTIONS ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
## 7. No Obligation to Use
|
||||
|
||||
You understand that the decision to include Your Contribution in any project or source repository is entirely at the discretion of the Project Owner, and this Agreement does not guarantee that Your Contributions will be included in any product.
|
||||
|
||||
## 8. Contributor Rights
|
||||
|
||||
You retain full copyright ownership of Your Contributions. Nothing in this Agreement shall be interpreted to prohibit you from licensing Your Contributions under different terms to third parties or from using Your Contributions for any other purpose.
|
||||
|
||||
## 9. Notification
|
||||
|
||||
You agree to notify the Project Owner of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
---
|
||||
|
||||
## How to Sign
|
||||
|
||||
To sign this CLA, comment on your Pull Request with:
|
||||
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
|
||||
Your signature will be recorded automatically.
|
||||
|
||||
---
|
||||
|
||||
*This CLA is based on the Apache Software Foundation Individual Contributor License Agreement v2.0.*
|
||||
@@ -4,6 +4,7 @@ Thank you for your interest in contributing to Auto Claude! This document provid
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Development Setup](#development-setup)
|
||||
@@ -28,6 +29,25 @@ Thank you for your interest in contributing to Auto Claude! This document provid
|
||||
- [Issue Reporting](#issue-reporting)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
All contributors must sign our Contributor License Agreement (CLA) before contributions can be accepted.
|
||||
|
||||
### Why We Require a CLA
|
||||
|
||||
Auto Claude is currently licensed under AGPL-3.0. The CLA ensures the project has proper licensing flexibility should we introduce additional licensing options (such as commercial/enterprise licenses) in the future.
|
||||
|
||||
You retain full copyright ownership of your contributions.
|
||||
|
||||
### How to Sign
|
||||
|
||||
1. Open a Pull Request
|
||||
2. The CLA bot will automatically comment with instructions
|
||||
3. Comment on the PR with: `I have read the CLA Document and I hereby sign the CLA`
|
||||
4. Done - you only need to sign once, and it applies to all future contributions
|
||||
|
||||
Read the full CLA here: [CLA.md](CLA.md)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before contributing, ensure you have the following installed:
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||

|
||||
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
|
||||
<!-- TOP_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
|
||||
<!-- TOP_VERSION_BADGE_END -->
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
@@ -13,15 +15,39 @@
|
||||
|
||||
## Download
|
||||
|
||||
Get the latest pre-built release for your platform:
|
||||
### Stable Release
|
||||
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) | Installer (NSIS) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) | M1/M2/M3 Macs |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) | Intel Macs |
|
||||
| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) | Universal |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) | Ubuntu/Debian |
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-amd64.deb) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
|
||||
@@ -30,16 +30,32 @@ Usage:
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
# Agent configuration registry
|
||||
AGENT_CONFIGS,
|
||||
# Base tools
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
# MCP tool lists
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
# Auto-Claude tool names
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
WEB_TOOLS,
|
||||
# Config functions
|
||||
get_agent_config,
|
||||
get_default_thinking_level,
|
||||
get_required_mcp_servers,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from .permissions import get_allowed_tools
|
||||
from .permissions import get_all_agent_types, get_allowed_tools
|
||||
from .registry import create_auto_claude_mcp_server, is_tools_available
|
||||
|
||||
__all__ = [
|
||||
@@ -47,14 +63,29 @@ __all__ = [
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
# Tool name constants
|
||||
# Agent configuration registry
|
||||
"AGENT_CONFIGS",
|
||||
"get_agent_config",
|
||||
"get_required_mcp_servers",
|
||||
"get_default_thinking_level",
|
||||
"get_all_agent_types",
|
||||
# Base tool lists
|
||||
"BASE_READ_TOOLS",
|
||||
"BASE_WRITE_TOOLS",
|
||||
"WEB_TOOLS",
|
||||
# MCP tool lists
|
||||
"CONTEXT7_TOOLS",
|
||||
"LINEAR_TOOLS",
|
||||
"GRAPHITI_MCP_TOOLS",
|
||||
"ELECTRON_TOOLS",
|
||||
"PUPPETEER_TOOLS",
|
||||
# Auto-Claude tool name constants
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Electron MCP
|
||||
"ELECTRON_TOOLS",
|
||||
# Config
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
|
||||
@@ -3,12 +3,32 @@ Tool Models and Constants
|
||||
==========================
|
||||
|
||||
Defines tool name constants and configuration for auto-claude MCP tools.
|
||||
|
||||
This module is the single source of truth for all tool definitions used by
|
||||
the Claude Agent SDK client. Tool lists are organized by category:
|
||||
|
||||
- Base tools: Core file operations (Read, Write, Edit, etc.)
|
||||
- Web tools: Documentation and research (WebFetch, WebSearch)
|
||||
- MCP tools: External integrations (Context7, Linear, Graphiti, etc.)
|
||||
- Auto-Claude tools: Custom build management tools
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# =============================================================================
|
||||
# Tool Name Constants
|
||||
# Base Tools (Built-in Claude Code tools)
|
||||
# =============================================================================
|
||||
|
||||
# Core file operation tools
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# Web tools for documentation lookup and research
|
||||
# Always available to all agents for accessing external information
|
||||
WEB_TOOLS = ["WebFetch", "WebSearch"]
|
||||
|
||||
# =============================================================================
|
||||
# Auto-Claude MCP Tools (Custom build management)
|
||||
# =============================================================================
|
||||
|
||||
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
|
||||
@@ -19,8 +39,54 @@ TOOL_RECORD_GOTCHA = "mcp__auto-claude__record_gotcha"
|
||||
TOOL_GET_SESSION_CONTEXT = "mcp__auto-claude__get_session_context"
|
||||
TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
|
||||
|
||||
# =============================================================================
|
||||
# External MCP Tools
|
||||
# =============================================================================
|
||||
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
LINEAR_TOOLS = [
|
||||
"mcp__linear-server__list_teams",
|
||||
"mcp__linear-server__get_team",
|
||||
"mcp__linear-server__list_projects",
|
||||
"mcp__linear-server__get_project",
|
||||
"mcp__linear-server__create_project",
|
||||
"mcp__linear-server__update_project",
|
||||
"mcp__linear-server__list_issues",
|
||||
"mcp__linear-server__get_issue",
|
||||
"mcp__linear-server__create_issue",
|
||||
"mcp__linear-server__update_issue",
|
||||
"mcp__linear-server__list_comments",
|
||||
"mcp__linear-server__create_comment",
|
||||
"mcp__linear-server__list_issue_statuses",
|
||||
"mcp__linear-server__list_issue_labels",
|
||||
"mcp__linear-server__list_users",
|
||||
"mcp__linear-server__get_user",
|
||||
]
|
||||
|
||||
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_URL is set)
|
||||
# See: https://github.com/getzep/graphiti
|
||||
GRAPHITI_MCP_TOOLS = [
|
||||
"mcp__graphiti-memory__search_nodes", # Search entity summaries
|
||||
"mcp__graphiti-memory__search_facts", # Search relationships between entities
|
||||
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
|
||||
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
|
||||
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Browser Automation MCP Tools (QA agents only)
|
||||
# =============================================================================
|
||||
|
||||
# Puppeteer MCP tools for web browser automation
|
||||
# Used for web frontend validation (non-Electron web apps)
|
||||
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
|
||||
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
|
||||
PUPPETEER_TOOLS = [
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
@@ -36,6 +102,7 @@ PUPPETEER_TOOLS = [
|
||||
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
|
||||
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
|
||||
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
|
||||
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
ELECTRON_TOOLS = [
|
||||
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
|
||||
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
|
||||
@@ -43,10 +110,6 @@ ELECTRON_TOOLS = [
|
||||
"mcp__electron__read_electron_logs", # Read console logs from Electron app
|
||||
]
|
||||
|
||||
# Base tools available to all agents
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
@@ -61,3 +124,280 @@ def is_electron_mcp_enabled() -> bool:
|
||||
via Chrome DevTools Protocol on the configured debug port.
|
||||
"""
|
||||
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Configuration Registry
|
||||
# =============================================================================
|
||||
# Single source of truth for phase → tools → MCP servers mapping.
|
||||
# This enables phase-aware tool control and context window optimization.
|
||||
|
||||
AGENT_CONFIGS = {
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SPEC CREATION PHASES (Minimal tools, fast startup)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"spec_gatherer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [], # No MCP needed - just reads project
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_researcher": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # Needs docs lookup
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_writer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [], # Just writes spec.md
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_critic": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [], # Self-critique, no external tools
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "ultrathink",
|
||||
},
|
||||
"spec_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_context": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_compaction": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# BUILD PHASES (Full tools + Graphiti memory)
|
||||
# Note: "linear" is conditional on project setting "update_linear_with_tasks"
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"planner": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"], # Only if project setting enabled
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"coder": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "none", # Coding doesn't use extended thinking
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"qa_reviewer": {
|
||||
# Read-only + Bash (for running tests) - reviewer should NOT edit code
|
||||
"tools": BASE_READ_TOOLS + ["Bash"] + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"], # For updating issue status
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"qa_fixer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# UTILITY PHASES (Minimal, no MCP)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"insights": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"commit_message": {
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_reviewer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ANALYSIS PHASES
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"batch_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"batch_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ROADMAP & IDEATION
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"roadmap_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"competitor_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # WebSearch for competitor research
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"ideation": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Config Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_agent_config(agent_type: str) -> dict:
|
||||
"""
|
||||
Get full configuration for an agent type.
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
|
||||
Returns:
|
||||
Configuration dict containing tools, mcp_servers, auto_claude_tools, thinking_default
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS (strict mode)
|
||||
"""
|
||||
if agent_type not in AGENT_CONFIGS:
|
||||
raise ValueError(
|
||||
f"Unknown agent type: '{agent_type}'. "
|
||||
f"Valid types: {sorted(AGENT_CONFIGS.keys())}"
|
||||
)
|
||||
return AGENT_CONFIGS[agent_type]
|
||||
|
||||
|
||||
def get_required_mcp_servers(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get MCP servers required for this agent type.
|
||||
|
||||
Handles dynamic server selection:
|
||||
- "browser" → electron (if is_electron) or puppeteer (if is_web_frontend)
|
||||
- "linear" → only if in mcp_servers_optional AND linear_enabled is True
|
||||
- "graphiti" → only if GRAPHITI_MCP_URL is set
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
|
||||
Returns:
|
||||
List of MCP server names to start
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
servers = list(config.get("mcp_servers", []))
|
||||
|
||||
# Handle optional servers (e.g., Linear if project setting enabled)
|
||||
optional = config.get("mcp_servers_optional", [])
|
||||
if "linear" in optional and linear_enabled:
|
||||
servers.append("linear")
|
||||
|
||||
# Handle dynamic "browser" → electron/puppeteer based on project type
|
||||
if "browser" in servers:
|
||||
servers = [s for s in servers if s != "browser"]
|
||||
if project_capabilities:
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
if is_electron and is_electron_mcp_enabled():
|
||||
servers.append("electron")
|
||||
elif is_web_frontend and not is_electron:
|
||||
servers.append("puppeteer")
|
||||
|
||||
# Filter graphiti if not enabled
|
||||
if "graphiti" in servers:
|
||||
if not os.environ.get("GRAPHITI_MCP_URL"):
|
||||
servers = [s for s in servers if s != "graphiti"]
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def get_default_thinking_level(agent_type: str) -> str:
|
||||
"""
|
||||
Get default thinking level string for agent type.
|
||||
|
||||
This returns the thinking level name (e.g., 'medium', 'high'), not the token budget.
|
||||
To convert to tokens, use phase_config.get_thinking_budget(level).
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
|
||||
Returns:
|
||||
Thinking level string (none, low, medium, high, ultrathink)
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
return config.get("thinking_default", "medium")
|
||||
|
||||
@@ -8,26 +8,29 @@ pollution and accidental misuse.
|
||||
Supports dynamic tool filtering based on project capabilities to optimize
|
||||
context window usage. For example, Electron tools are only included for
|
||||
Electron projects, not for Next.js or CLI projects.
|
||||
|
||||
This module now uses AGENT_CONFIGS from models.py as the single source of truth
|
||||
for tool permissions. The get_allowed_tools() function remains the primary API
|
||||
for backwards compatibility.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
AGENT_CONFIGS,
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
is_electron_mcp_enabled,
|
||||
get_agent_config,
|
||||
get_required_mcp_servers,
|
||||
)
|
||||
from .registry import is_tools_available
|
||||
|
||||
|
||||
def get_allowed_tools(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get the list of allowed tools for a specific agent type.
|
||||
@@ -35,113 +38,80 @@ def get_allowed_tools(
|
||||
This ensures each agent only sees tools relevant to their role,
|
||||
preventing context pollution and accidental misuse.
|
||||
|
||||
When project_capabilities is provided, MCP tools are filtered based on
|
||||
the project type. For example:
|
||||
- Electron projects get Electron MCP tools
|
||||
- Web frontends (non-Electron) get Puppeteer MCP tools
|
||||
- CLI projects get neither
|
||||
Uses AGENT_CONFIGS as the single source of truth for tool permissions.
|
||||
Dynamic MCP tools are added based on project capabilities and required servers.
|
||||
|
||||
Args:
|
||||
agent_type: One of 'planner', 'coder', 'qa_reviewer', 'qa_fixer'
|
||||
agent_type: Agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
project_capabilities: Optional dict from detect_project_capabilities()
|
||||
containing flags like is_electron, is_web_frontend, etc.
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
|
||||
Returns:
|
||||
List of allowed tool names
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Auto-claude tool mappings by agent type
|
||||
tool_mappings = {
|
||||
"planner": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
},
|
||||
"coder": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
},
|
||||
"qa_reviewer": {
|
||||
"base": BASE_READ_TOOLS + ["Bash"], # Can run tests but not edit
|
||||
"auto_claude": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
},
|
||||
"pr_reviewer": {
|
||||
# PR reviewers can ONLY read - no bash, no edits, no writes
|
||||
# This prevents the agent from switching branches or making changes
|
||||
"base": BASE_READ_TOOLS,
|
||||
"auto_claude": [], # No auto-claude tools needed for PR review
|
||||
},
|
||||
"qa_fixer": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
},
|
||||
}
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
if agent_type not in tool_mappings:
|
||||
# Default to coder tools
|
||||
agent_type = "coder"
|
||||
# Start with base tools from config
|
||||
tools = list(config.get("tools", []))
|
||||
|
||||
mapping = tool_mappings[agent_type]
|
||||
tools = mapping["base"] + mapping["auto_claude"]
|
||||
# Get required MCP servers for this agent
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
)
|
||||
|
||||
# Add MCP tools for QA agents only, based on project capabilities
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
tools.extend(_get_qa_mcp_tools(project_capabilities))
|
||||
# Add auto-claude tools ONLY if the MCP server is available
|
||||
# This prevents allowing tools that won't work because the server isn't running
|
||||
if "auto-claude" in required_servers and is_tools_available():
|
||||
tools.extend(config.get("auto_claude_tools", []))
|
||||
|
||||
# Add MCP tool names based on required servers
|
||||
tools.extend(_get_mcp_tools_for_servers(required_servers))
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _get_qa_mcp_tools(project_capabilities: dict | None) -> list[str]:
|
||||
def _get_mcp_tools_for_servers(servers: list[str]) -> list[str]:
|
||||
"""
|
||||
Get the list of MCP tools for QA agents based on project capabilities.
|
||||
Get the list of MCP tools for a list of required servers.
|
||||
|
||||
This function determines which MCP tools to include based on:
|
||||
1. Project type detection (Electron, web frontend, etc.)
|
||||
2. Environment variables (ELECTRON_MCP_ENABLED)
|
||||
Maps server names to their corresponding tool lists.
|
||||
|
||||
Args:
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
servers: List of MCP server names (e.g., ['context7', 'linear', 'electron'])
|
||||
|
||||
Returns:
|
||||
List of MCP tool names to include
|
||||
List of MCP tool names for all specified servers
|
||||
"""
|
||||
tools = []
|
||||
|
||||
# If no capabilities provided, fall back to legacy behavior
|
||||
# (check env var only)
|
||||
if project_capabilities is None:
|
||||
if is_electron_mcp_enabled():
|
||||
for server in servers:
|
||||
if server == "context7":
|
||||
tools.extend(CONTEXT7_TOOLS)
|
||||
elif server == "linear":
|
||||
tools.extend(LINEAR_TOOLS)
|
||||
elif server == "graphiti":
|
||||
tools.extend(GRAPHITI_MCP_TOOLS)
|
||||
elif server == "electron":
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
return tools
|
||||
|
||||
# Project-capability-based tool selection
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
# Electron projects get Electron MCP tools (if enabled)
|
||||
if is_electron and is_electron_mcp_enabled():
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
|
||||
# Web frontends (non-Electron) get Puppeteer tools
|
||||
# Puppeteer is always available, no env var check needed
|
||||
if is_web_frontend and not is_electron:
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
elif server == "puppeteer":
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
# auto-claude tools are already added via config["auto_claude_tools"]
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def get_all_agent_types() -> list[str]:
|
||||
"""
|
||||
Get all registered agent types.
|
||||
|
||||
Returns:
|
||||
Sorted list of all agent type identifiers
|
||||
"""
|
||||
return sorted(AGENT_CONFIGS.keys())
|
||||
|
||||
@@ -366,19 +366,19 @@ async def run_insight_extraction(
|
||||
cwd = str(project_dir.resolve()) if project_dir else os.getcwd()
|
||||
|
||||
try:
|
||||
# Create a minimal SDK client for insight extraction
|
||||
# No tools needed - just text generation
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
allowed_tools=[], # No tools needed for extraction
|
||||
max_turns=1, # Single turn extraction
|
||||
cwd=cwd,
|
||||
)
|
||||
# Use simple_client for insight extraction
|
||||
from pathlib import Path
|
||||
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="insights",
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
cwd=Path(cwd) if cwd else None,
|
||||
)
|
||||
|
||||
# Use async context manager
|
||||
|
||||
@@ -197,19 +197,16 @@ async def _call_claude_haiku(prompt: str) -> str:
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.simple_client import create_simple_client
|
||||
except ImportError:
|
||||
logger.warning("claude_agent_sdk not installed")
|
||||
logger.warning("core.simple_client not available")
|
||||
return ""
|
||||
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
client = create_simple_client(
|
||||
agent_type="commit_message",
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
+159
-169
@@ -6,20 +6,27 @@ Functions for creating and configuring the Claude Agent SDK client.
|
||||
|
||||
All AI interactions should use `create_client()` to ensure consistent OAuth authentication
|
||||
and proper tool/MCP configuration. For simple message calls without full agent sessions,
|
||||
use `ClaudeSDKClient` directly with `allowed_tools=[]` and `max_turns=1`.
|
||||
use `create_simple_client()` from `core.simple_client`.
|
||||
|
||||
The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
|
||||
single source of truth for phase-aware tool and MCP server configuration.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from auto_claude_tools import (
|
||||
from agents.tools_pkg import (
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
get_required_mcp_servers,
|
||||
is_tools_available,
|
||||
)
|
||||
from auto_claude_tools import (
|
||||
get_allowed_tools as get_agent_allowed_tools,
|
||||
)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
@@ -59,78 +66,28 @@ def get_electron_debug_port() -> int:
|
||||
return int(os.environ.get("ELECTRON_DEBUG_PORT", "9222"))
|
||||
|
||||
|
||||
# Puppeteer MCP tools for browser automation
|
||||
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
|
||||
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
|
||||
PUPPETEER_TOOLS = [
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
"mcp__puppeteer__puppeteer_screenshot",
|
||||
"mcp__puppeteer__puppeteer_click",
|
||||
"mcp__puppeteer__puppeteer_fill",
|
||||
"mcp__puppeteer__puppeteer_select",
|
||||
"mcp__puppeteer__puppeteer_hover",
|
||||
"mcp__puppeteer__puppeteer_evaluate",
|
||||
]
|
||||
def should_use_claude_md() -> bool:
|
||||
"""Check if CLAUDE.md instructions should be included in system prompt."""
|
||||
return os.environ.get("USE_CLAUDE_MD", "").lower() == "true"
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
LINEAR_TOOLS = [
|
||||
"mcp__linear-server__list_teams",
|
||||
"mcp__linear-server__get_team",
|
||||
"mcp__linear-server__list_projects",
|
||||
"mcp__linear-server__get_project",
|
||||
"mcp__linear-server__create_project",
|
||||
"mcp__linear-server__update_project",
|
||||
"mcp__linear-server__list_issues",
|
||||
"mcp__linear-server__get_issue",
|
||||
"mcp__linear-server__create_issue",
|
||||
"mcp__linear-server__update_issue",
|
||||
"mcp__linear-server__list_comments",
|
||||
"mcp__linear-server__create_comment",
|
||||
"mcp__linear-server__list_issue_statuses",
|
||||
"mcp__linear-server__list_issue_labels",
|
||||
"mcp__linear-server__list_users",
|
||||
"mcp__linear-server__get_user",
|
||||
]
|
||||
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
]
|
||||
def load_claude_md(project_dir: Path) -> str | None:
|
||||
"""
|
||||
Load CLAUDE.md content from project root if it exists.
|
||||
|
||||
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_ENABLED is set)
|
||||
# See: https://github.com/getzep/graphiti
|
||||
GRAPHITI_MCP_TOOLS = [
|
||||
"mcp__graphiti-memory__search_nodes", # Search entity summaries
|
||||
"mcp__graphiti-memory__search_facts", # Search relationships between entities
|
||||
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
|
||||
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
|
||||
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
|
||||
]
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
|
||||
# Electron MCP tools for desktop app automation (when ELECTRON_MCP_ENABLED is set)
|
||||
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
|
||||
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
|
||||
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
|
||||
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
# See GitHub issue #74.
|
||||
ELECTRON_TOOLS = [
|
||||
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
|
||||
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
|
||||
"mcp__electron__send_command_to_electron", # Send commands (click, fill, evaluate JS)
|
||||
"mcp__electron__read_electron_logs", # Read console logs from Electron app
|
||||
]
|
||||
|
||||
# Built-in tools
|
||||
BUILTIN_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
]
|
||||
Returns:
|
||||
Content of CLAUDE.md if found, None otherwise
|
||||
"""
|
||||
claude_md_path = project_dir / "CLAUDE.md"
|
||||
if claude_md_path.exists():
|
||||
try:
|
||||
return claude_md_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def create_client(
|
||||
@@ -144,12 +101,16 @@ def create_client(
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
|
||||
Uses AGENT_CONFIGS for phase-aware tool and MCP server configuration.
|
||||
Only starts MCP servers that the agent actually needs, reducing context
|
||||
window bloat and startup latency.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project (working directory)
|
||||
spec_dir: Directory containing the spec (for settings file)
|
||||
model: Claude model to use
|
||||
agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer'
|
||||
This determines which custom auto-claude tools are available.
|
||||
agent_type: Agent type identifier from AGENT_CONFIGS
|
||||
(e.g., 'coder', 'planner', 'qa_reviewer', 'spec_gatherer')
|
||||
max_thinking_tokens: Token budget for extended thinking (None = disabled)
|
||||
- ultrathink: 16000 (spec creation)
|
||||
- high: 10000 (QA review)
|
||||
@@ -162,6 +123,9 @@ def create_client(
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
|
||||
Security layers (defense in depth):
|
||||
1. Sandbox - OS-level bash command isolation prevents filesystem escape
|
||||
2. Permissions - File operations restricted to project_dir only
|
||||
@@ -188,68 +152,78 @@ def create_client(
|
||||
project_index = load_project_index(project_dir)
|
||||
project_capabilities = detect_project_capabilities(project_index)
|
||||
|
||||
# Build the list of allowed tools
|
||||
# Start with agent-specific tools (includes base tools + auto-claude tools)
|
||||
# Pass project capabilities for dynamic MCP tool filtering
|
||||
if auto_claude_tools_enabled:
|
||||
allowed_tools_list = get_agent_allowed_tools(agent_type, project_capabilities)
|
||||
else:
|
||||
allowed_tools_list = [*BUILTIN_TOOLS]
|
||||
# Get allowed tools using phase-aware configuration
|
||||
# This respects AGENT_CONFIGS and only includes tools the agent needs
|
||||
allowed_tools_list = get_allowed_tools(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
)
|
||||
|
||||
# Check if Graphiti MCP is enabled
|
||||
graphiti_mcp_enabled = is_graphiti_mcp_enabled()
|
||||
# Get required MCP servers for this agent type
|
||||
# This is the key optimization - only start servers the agent needs
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
)
|
||||
|
||||
# Check if Electron MCP is enabled (for QA agents testing Electron apps)
|
||||
electron_mcp_enabled = is_electron_mcp_enabled()
|
||||
# Check if Graphiti MCP is enabled (already filtered by get_required_mcp_servers)
|
||||
graphiti_mcp_enabled = "graphiti" in required_servers
|
||||
|
||||
# Add external MCP tools based on project capabilities
|
||||
# This saves context window by only including relevant tools
|
||||
allowed_tools_list.extend(CONTEXT7_TOOLS) # Always available
|
||||
if linear_enabled:
|
||||
allowed_tools_list.extend(LINEAR_TOOLS)
|
||||
if graphiti_mcp_enabled:
|
||||
allowed_tools_list.extend(GRAPHITI_MCP_TOOLS)
|
||||
# Note: Browser automation tools (ELECTRON_TOOLS, PUPPETEER_TOOLS) are already
|
||||
# added by get_agent_allowed_tools() via _get_qa_mcp_tools() for QA agents
|
||||
|
||||
# Determine which browser automation tools to allow based on project type
|
||||
# Note: Must check "not is_electron" for Puppeteer to avoid tool mismatch
|
||||
# when Electron MCP is disabled for an Electron project
|
||||
# Determine browser tools for permissions (already in allowed_tools_list)
|
||||
browser_tools_permissions = []
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
browser_tools_permissions = ELECTRON_TOOLS
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
# Only add Puppeteer for non-Electron web frontends
|
||||
browser_tools_permissions = PUPPETEER_TOOLS
|
||||
if "electron" in required_servers:
|
||||
browser_tools_permissions = ELECTRON_TOOLS
|
||||
elif "puppeteer" in required_servers:
|
||||
browser_tools_permissions = PUPPETEER_TOOLS
|
||||
|
||||
# Create comprehensive security settings
|
||||
# Note: Using relative paths ("./**") restricts access to project directory
|
||||
# since cwd is set to project_dir
|
||||
# Note: Using both relative paths ("./**") and absolute paths to handle
|
||||
# cases where Claude uses absolute paths for file operations
|
||||
project_path_str = str(project_dir.resolve())
|
||||
security_settings = {
|
||||
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits", # Auto-approve edits within allowed directories
|
||||
"allow": [
|
||||
# Allow all file operations within the project directory
|
||||
# Include both relative (./**) and absolute paths for compatibility
|
||||
"Read(./**)",
|
||||
"Write(./**)",
|
||||
"Edit(./**)",
|
||||
"Glob(./**)",
|
||||
"Grep(./**)",
|
||||
# Also allow absolute paths (Claude sometimes uses full paths)
|
||||
f"Read({project_path_str}/**)",
|
||||
f"Write({project_path_str}/**)",
|
||||
f"Edit({project_path_str}/**)",
|
||||
f"Glob({project_path_str}/**)",
|
||||
f"Grep({project_path_str}/**)",
|
||||
# Bash permission granted here, but actual commands are validated
|
||||
# by the bash_security_hook (see security.py for allowed commands)
|
||||
"Bash(*)",
|
||||
# Allow Context7 MCP tools for documentation lookup
|
||||
*CONTEXT7_TOOLS,
|
||||
# Allow Linear MCP tools for project management (if enabled)
|
||||
*(LINEAR_TOOLS if linear_enabled else []),
|
||||
# Allow Graphiti MCP tools for knowledge graph memory (if enabled)
|
||||
*(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []),
|
||||
# Allow browser automation tools based on project type
|
||||
*browser_tools_permissions,
|
||||
# Allow web tools for documentation and research
|
||||
"WebFetch(*)",
|
||||
"WebSearch(*)",
|
||||
# Allow MCP tools based on required servers
|
||||
# Format: tool_name(*) allows all arguments
|
||||
*(
|
||||
[f"{tool}(*)" for tool in CONTEXT7_TOOLS]
|
||||
if "context7" in required_servers
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[f"{tool}(*)" for tool in LINEAR_TOOLS]
|
||||
if "linear" in required_servers
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[f"{tool}(*)" for tool in GRAPHITI_MCP_TOOLS]
|
||||
if graphiti_mcp_enabled
|
||||
else []
|
||||
),
|
||||
*[f"{tool}(*)" for tool in browser_tools_permissions],
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -268,24 +242,26 @@ def create_client(
|
||||
else:
|
||||
print(" - Extended thinking: disabled")
|
||||
|
||||
# Build list of MCP servers for display
|
||||
mcp_servers_list = ["context7 (documentation)"]
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
mcp_servers_list.append(
|
||||
f"electron (desktop automation, port {get_electron_debug_port()})"
|
||||
)
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
mcp_servers_list.append("puppeteer (browser automation)")
|
||||
if linear_enabled:
|
||||
# Build list of MCP servers for display based on required_servers
|
||||
mcp_servers_list = []
|
||||
if "context7" in required_servers:
|
||||
mcp_servers_list.append("context7 (documentation)")
|
||||
if "electron" in required_servers:
|
||||
mcp_servers_list.append(
|
||||
f"electron (desktop automation, port {get_electron_debug_port()})"
|
||||
)
|
||||
if "puppeteer" in required_servers:
|
||||
mcp_servers_list.append("puppeteer (browser automation)")
|
||||
if "linear" in required_servers:
|
||||
mcp_servers_list.append("linear (project management)")
|
||||
if graphiti_mcp_enabled:
|
||||
mcp_servers_list.append("graphiti-memory (knowledge graph)")
|
||||
if auto_claude_tools_enabled:
|
||||
if "auto-claude" in required_servers and auto_claude_tools_enabled:
|
||||
mcp_servers_list.append(f"auto-claude ({agent_type} tools)")
|
||||
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
|
||||
if mcp_servers_list:
|
||||
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
|
||||
else:
|
||||
print(" - MCP servers: none (minimal configuration)")
|
||||
|
||||
# Show detected project capabilities for QA agents
|
||||
if agent_type in ("qa_reviewer", "qa_fixer") and any(project_capabilities.values()):
|
||||
@@ -297,65 +273,79 @@ def create_client(
|
||||
print(f" - Project capabilities: {', '.join(caps)}")
|
||||
print()
|
||||
|
||||
# Configure MCP servers
|
||||
mcp_servers = {
|
||||
"context7": {"command": "npx", "args": ["-y", "@upstash/context7-mcp"]},
|
||||
}
|
||||
# Configure MCP servers - ONLY start servers that are required
|
||||
# This is the key optimization to reduce context bloat and startup latency
|
||||
mcp_servers = {}
|
||||
|
||||
# Add browser automation MCP server based on project type
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
# Electron MCP for desktop apps
|
||||
# Electron app must be started with --remote-debugging-port=<port>
|
||||
mcp_servers["electron"] = {
|
||||
"command": "npm",
|
||||
"args": ["exec", "electron-mcp-server"],
|
||||
}
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
# Puppeteer for web frontends (not Electron)
|
||||
mcp_servers["puppeteer"] = {
|
||||
"command": "npx",
|
||||
"args": ["puppeteer-mcp-server"],
|
||||
}
|
||||
if "context7" in required_servers:
|
||||
mcp_servers["context7"] = {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
}
|
||||
|
||||
# Add Linear MCP server if enabled
|
||||
if linear_enabled:
|
||||
if "electron" in required_servers:
|
||||
# Electron MCP for desktop apps
|
||||
# Electron app must be started with --remote-debugging-port=<port>
|
||||
mcp_servers["electron"] = {
|
||||
"command": "npm",
|
||||
"args": ["exec", "electron-mcp-server"],
|
||||
}
|
||||
|
||||
if "puppeteer" in required_servers:
|
||||
# Puppeteer for web frontends (not Electron)
|
||||
mcp_servers["puppeteer"] = {
|
||||
"command": "npx",
|
||||
"args": ["puppeteer-mcp-server"],
|
||||
}
|
||||
|
||||
if "linear" in required_servers:
|
||||
mcp_servers["linear"] = {
|
||||
"type": "http",
|
||||
"url": "https://mcp.linear.app/mcp",
|
||||
"headers": {"Authorization": f"Bearer {linear_api_key}"},
|
||||
}
|
||||
|
||||
# Add Graphiti MCP server if enabled
|
||||
# Graphiti MCP server for knowledge graph memory (uses embedded LadybugDB)
|
||||
# Graphiti MCP server for knowledge graph memory
|
||||
if graphiti_mcp_enabled:
|
||||
mcp_servers["graphiti-memory"] = {
|
||||
"type": "http",
|
||||
"url": get_graphiti_mcp_url(),
|
||||
}
|
||||
|
||||
# Add custom auto-claude MCP server if available
|
||||
auto_claude_mcp_server = None
|
||||
if auto_claude_tools_enabled:
|
||||
# Add custom auto-claude MCP server if required and available
|
||||
if "auto-claude" in required_servers and auto_claude_tools_enabled:
|
||||
auto_claude_mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir)
|
||||
if auto_claude_mcp_server:
|
||||
mcp_servers["auto-claude"] = auto_claude_mcp_server
|
||||
|
||||
# Build system prompt
|
||||
base_prompt = (
|
||||
f"You are an expert full-stack developer building production-quality software. "
|
||||
f"Your working directory is: {project_dir.resolve()}\n"
|
||||
f"Your filesystem access is RESTRICTED to this directory only. "
|
||||
f"Use relative paths (starting with ./) for all file operations. "
|
||||
f"Never use absolute paths or try to access files outside your working directory.\n\n"
|
||||
f"You follow existing code patterns, write clean maintainable code, and verify "
|
||||
f"your work through thorough testing. You communicate progress through Git commits "
|
||||
f"and build-progress.txt updates."
|
||||
)
|
||||
|
||||
# Include CLAUDE.md if enabled and present
|
||||
if should_use_claude_md():
|
||||
claude_md_content = load_claude_md(project_dir)
|
||||
if claude_md_content:
|
||||
base_prompt = f"{base_prompt}\n\n# Project Instructions (from CLAUDE.md)\n\n{claude_md_content}"
|
||||
print(" - CLAUDE.md: included in system prompt")
|
||||
else:
|
||||
print(" - CLAUDE.md: not found in project root")
|
||||
else:
|
||||
print(" - CLAUDE.md: disabled by project settings")
|
||||
print()
|
||||
|
||||
# Build options dict, conditionally including output_format
|
||||
options_kwargs = {
|
||||
"model": model,
|
||||
"system_prompt": (
|
||||
f"You are an expert full-stack developer building production-quality software. "
|
||||
f"Your working directory is: {project_dir.resolve()}\n"
|
||||
f"Your filesystem access is RESTRICTED to this directory only. "
|
||||
f"Use relative paths (starting with ./) for all file operations. "
|
||||
f"Never use absolute paths or try to access files outside your working directory.\n\n"
|
||||
f"You follow existing code patterns, write clean maintainable code, and verify "
|
||||
f"your work through thorough testing. You communicate progress through Git commits "
|
||||
f"and build-progress.txt updates."
|
||||
),
|
||||
"system_prompt": base_prompt,
|
||||
"allowed_tools": allowed_tools_list,
|
||||
"mcp_servers": mcp_servers,
|
||||
"hooks": {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Simple Claude SDK Client Factory
|
||||
================================
|
||||
|
||||
Factory for creating minimal Claude SDK clients for single-turn utility operations
|
||||
like commit message generation, merge conflict resolution, and batch analysis.
|
||||
|
||||
These clients don't need full security configurations, MCP servers, or hooks.
|
||||
Use `create_client()` from `core.client` for full agent sessions with security.
|
||||
|
||||
Example usage:
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
# For commit message generation (text-only, no tools)
|
||||
client = create_simple_client(agent_type="commit_message")
|
||||
|
||||
# For merge conflict resolution (text-only, no tools)
|
||||
client = create_simple_client(agent_type="merge_resolver")
|
||||
|
||||
# For insights extraction (read tools only)
|
||||
client = create_simple_client(agent_type="insights", cwd=project_dir)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from phase_config import get_thinking_budget
|
||||
|
||||
|
||||
def create_simple_client(
|
||||
agent_type: str = "merge_resolver",
|
||||
model: str = "claude-haiku-4-5-20251001",
|
||||
system_prompt: str | None = None,
|
||||
cwd: Path | None = None,
|
||||
max_turns: int = 1,
|
||||
max_thinking_tokens: int | None = None,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a minimal Claude SDK client for single-turn utility operations.
|
||||
|
||||
This factory creates lightweight clients without MCP servers, security hooks,
|
||||
or full permission configurations. Use for text-only analysis tasks.
|
||||
|
||||
Args:
|
||||
agent_type: Agent type from AGENT_CONFIGS. Determines available tools.
|
||||
Common utility types:
|
||||
- "merge_resolver" - Text-only merge conflict analysis
|
||||
- "commit_message" - Text-only commit message generation
|
||||
- "insights" - Read-only code insight extraction
|
||||
- "batch_analysis" - Read-only batch issue analysis
|
||||
- "batch_validation" - Read-only validation
|
||||
model: Claude model to use (defaults to Haiku for fast/cheap operations)
|
||||
system_prompt: Optional custom system prompt (for specialized tasks)
|
||||
cwd: Working directory for file operations (optional)
|
||||
max_turns: Maximum conversation turns (default: 1 for single-turn)
|
||||
max_thinking_tokens: Override thinking budget (None = use agent default from
|
||||
AGENT_CONFIGS, converted using phase_config.THINKING_BUDGET_MAP)
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient for single-turn operations
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token()
|
||||
import os
|
||||
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Get environment variables for SDK
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
# Get tools from config (no MCP tools for simple clients)
|
||||
allowed_tools = list(config.get("tools", []))
|
||||
|
||||
# Determine thinking budget using the single source of truth (phase_config.py)
|
||||
if max_thinking_tokens is None:
|
||||
thinking_level = get_default_thinking_level(agent_type)
|
||||
max_thinking_tokens = get_thinking_budget(thinking_level)
|
||||
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
allowed_tools=allowed_tools,
|
||||
max_turns=max_turns,
|
||||
cwd=str(cwd.resolve()) if cwd else None,
|
||||
env=sdk_env,
|
||||
max_thinking_tokens=max_thinking_tokens,
|
||||
)
|
||||
)
|
||||
@@ -1407,23 +1407,20 @@ async def _merge_file_with_ai_async(
|
||||
|
||||
# Call Claude Haiku for fast merge
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.simple_client import create_simple_client
|
||||
except ImportError:
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error="claude_agent_sdk not installed",
|
||||
error="core.simple_client not available",
|
||||
)
|
||||
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=AI_MERGE_SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
client = create_simple_client(
|
||||
agent_type="merge_resolver",
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=AI_MERGE_SYSTEM_PROMPT,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
|
||||
response_text = ""
|
||||
|
||||
@@ -143,6 +143,43 @@ def choose_workspace(
|
||||
return WorkspaceMode.ISOLATED
|
||||
|
||||
|
||||
def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[str]:
|
||||
"""
|
||||
Copy .env files from project root to worktree (without overwriting).
|
||||
|
||||
This ensures the worktree has access to environment variables needed
|
||||
to run the project (e.g., API keys, database URLs).
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of copied file names
|
||||
"""
|
||||
copied = []
|
||||
# Common .env file patterns - copy if they exist
|
||||
env_patterns = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.development.local",
|
||||
".env.test",
|
||||
".env.test.local",
|
||||
]
|
||||
|
||||
for pattern in env_patterns:
|
||||
env_file = project_dir / pattern
|
||||
if env_file.is_file():
|
||||
target = worktree_path / pattern
|
||||
if not target.exists():
|
||||
shutil.copy2(env_file, target)
|
||||
copied.append(pattern)
|
||||
debug(MODULE, f"Copied {pattern} to worktree")
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
def copy_spec_to_worktree(
|
||||
source_spec_dir: Path,
|
||||
worktree_path: Path,
|
||||
@@ -223,6 +260,13 @@ def setup_workspace(
|
||||
# Get or create worktree for THIS SPECIFIC SPEC
|
||||
worktree_info = manager.get_or_create_worktree(spec_name)
|
||||
|
||||
# Copy .env files to worktree so user can run the project
|
||||
copied_env_files = copy_env_files_to_worktree(project_dir, worktree_info.path)
|
||||
if copied_env_files:
|
||||
print_status(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Copy spec files to worktree if provided
|
||||
localized_spec_dir = None
|
||||
if source_spec_dir and source_spec_dir.exists():
|
||||
|
||||
@@ -0,0 +1,862 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Script for Ollama Embedding Memory Integration
|
||||
====================================================
|
||||
|
||||
This test validates that the memory system works correctly with local Ollama
|
||||
embedding models (like embeddinggemma, nomic-embed-text) for creating and
|
||||
retrieving memories in the hybrid RAG system.
|
||||
|
||||
The test covers:
|
||||
1. Ollama embedding generation (direct API test)
|
||||
2. Creating memories with Ollama embeddings via GraphitiMemory
|
||||
3. Retrieving memories via semantic search
|
||||
4. Verifying the full create → store → retrieve cycle
|
||||
|
||||
Prerequisites:
|
||||
1. Install Ollama: https://ollama.ai/
|
||||
2. Pull an embedding model:
|
||||
ollama pull embeddinggemma # 768 dimensions (lightweight)
|
||||
ollama pull nomic-embed-text # 768 dimensions (good quality)
|
||||
3. Pull an LLM model (for knowledge graph construction):
|
||||
ollama pull deepseek-r1:7b # or llama3.2:3b, mistral:7b
|
||||
4. Start Ollama server: ollama serve
|
||||
5. Configure environment:
|
||||
export GRAPHITI_ENABLED=true
|
||||
export GRAPHITI_LLM_PROVIDER=ollama
|
||||
export GRAPHITI_EMBEDDER_PROVIDER=ollama
|
||||
export OLLAMA_LLM_MODEL=deepseek-r1:7b
|
||||
export OLLAMA_EMBEDDING_MODEL=embeddinggemma
|
||||
export OLLAMA_EMBEDDING_DIM=768
|
||||
|
||||
NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
|
||||
For full offline operation, set a dummy key: export OPENAI_API_KEY=dummy
|
||||
The reranker will fail at search time, but embedding creation works.
|
||||
For production, use OpenAI API key for best search quality.
|
||||
|
||||
Usage:
|
||||
cd apps/backend
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py
|
||||
|
||||
# Run specific tests:
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test create
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
auto_claude_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(auto_claude_dir))
|
||||
|
||||
# Load .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = auto_claude_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
except ImportError:
|
||||
print("Note: python-dotenv not installed, using environment variables only")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def print_header(title: str):
|
||||
"""Print a section header."""
|
||||
print("\n" + "=" * 70)
|
||||
print(f" {title}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
|
||||
def print_result(label: str, value: str, success: bool = True):
|
||||
"""Print a result line."""
|
||||
status = "PASS" if success else "FAIL"
|
||||
print(f" [{status}] {label}: {value}")
|
||||
|
||||
|
||||
def print_info(message: str):
|
||||
"""Print an info line."""
|
||||
print(f" INFO: {message}")
|
||||
|
||||
|
||||
def print_step(step: int, message: str):
|
||||
"""Print a step indicator."""
|
||||
print(f"\n Step {step}: {message}")
|
||||
|
||||
|
||||
def apply_ladybug_monkeypatch():
|
||||
"""Apply LadybugDB monkeypatch for embedded database support."""
|
||||
try:
|
||||
import real_ladybug
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
import kuzu # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 1: Ollama Embedding Generation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_ollama_embeddings() -> bool:
|
||||
"""
|
||||
Test Ollama embedding generation directly via API.
|
||||
|
||||
This validates that Ollama is running and can generate embeddings
|
||||
with the configured model.
|
||||
"""
|
||||
print_header("Test 1: Ollama Embedding Generation")
|
||||
|
||||
ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma")
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "768"))
|
||||
|
||||
print(f" Ollama Model: {ollama_model}")
|
||||
print(f" Base URL: {ollama_base_url}")
|
||||
print(f" Expected Dimension: {expected_dim}")
|
||||
print()
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print_result("requests library", "Not installed - pip install requests", False)
|
||||
return False
|
||||
|
||||
# Step 1: Check Ollama is running
|
||||
print_step(1, "Checking Ollama server status")
|
||||
try:
|
||||
resp = requests.get(f"{ollama_base_url}/api/tags", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
"Ollama server",
|
||||
f"Not responding (status {resp.status_code})",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
models = resp.json().get("models", [])
|
||||
model_names = [m.get("name", "") for m in models]
|
||||
print_result("Ollama server", f"Running with {len(models)} models", True)
|
||||
|
||||
# Check if embedding model is available
|
||||
embedding_model_found = any(
|
||||
ollama_model in name or ollama_model.split(":")[0] in name
|
||||
for name in model_names
|
||||
)
|
||||
if not embedding_model_found:
|
||||
print_info(f"Model '{ollama_model}' not found. Available: {model_names}")
|
||||
print_info(f"Pull it with: ollama pull {ollama_model}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print_result(
|
||||
"Ollama server",
|
||||
"Not running - start with 'ollama serve'",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
# Step 2: Generate test embedding
|
||||
print_step(2, "Generating test embeddings")
|
||||
|
||||
test_texts = [
|
||||
"This is a test memory about implementing OAuth authentication.",
|
||||
"The user prefers using TypeScript for frontend development.",
|
||||
"A gotcha discovered: always validate JWT tokens on the server side.",
|
||||
]
|
||||
|
||||
embeddings = []
|
||||
for i, text in enumerate(test_texts):
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": text},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
f"Embedding {i + 1}",
|
||||
f"Failed: {resp.status_code} - {resp.text[:100]}",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
data = resp.json()
|
||||
embedding = data.get("embedding", [])
|
||||
embeddings.append(embedding)
|
||||
|
||||
print_result(
|
||||
f"Embedding {i + 1}",
|
||||
f"Generated {len(embedding)} dimensions",
|
||||
True,
|
||||
)
|
||||
|
||||
# Step 3: Validate embedding dimensions
|
||||
print_step(3, "Validating embedding dimensions")
|
||||
|
||||
for i, embedding in enumerate(embeddings):
|
||||
if len(embedding) != expected_dim:
|
||||
print_result(
|
||||
f"Embedding {i + 1} dimension",
|
||||
f"Mismatch! Got {len(embedding)}, expected {expected_dim}",
|
||||
False,
|
||||
)
|
||||
print_info(f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config")
|
||||
return False
|
||||
print_result(
|
||||
f"Embedding {i + 1} dimension", f"{len(embedding)} matches expected", True
|
||||
)
|
||||
|
||||
# Step 4: Test embedding similarity (basic sanity check)
|
||||
print_step(4, "Testing embedding similarity")
|
||||
|
||||
def cosine_similarity(a, b):
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
dot_product = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = sum(x * x for x in a) ** 0.5
|
||||
norm_b = sum(x * x for x in b) ** 0.5
|
||||
return dot_product / (norm_a * norm_b) if norm_a and norm_b else 0
|
||||
|
||||
# Generate embedding for a similar query
|
||||
query = "OAuth authentication implementation"
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": query},
|
||||
timeout=60,
|
||||
)
|
||||
query_embedding = resp.json().get("embedding", [])
|
||||
|
||||
similarities = [cosine_similarity(query_embedding, emb) for emb in embeddings]
|
||||
|
||||
print(f" Query: '{query}'")
|
||||
print(" Similarities to test texts:")
|
||||
for i, (text, sim) in enumerate(zip(test_texts, similarities)):
|
||||
print(f" {i + 1}. {sim:.4f} - '{text[:50]}...'")
|
||||
|
||||
# First text (about OAuth) should have highest similarity to OAuth query
|
||||
if similarities[0] > similarities[1] and similarities[0] > similarities[2]:
|
||||
print_result("Semantic similarity", "OAuth query matches OAuth text best", True)
|
||||
else:
|
||||
print_info("Similarity ordering may vary - embeddings are still working")
|
||||
|
||||
print()
|
||||
print_result("Ollama Embeddings", "All tests passed", True)
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 2: Memory Creation with Ollama
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_memory_creation(test_db_path: Path) -> tuple[Path, Path, bool]:
|
||||
"""
|
||||
Test creating memories using GraphitiMemory with Ollama embeddings.
|
||||
|
||||
Returns:
|
||||
Tuple of (spec_dir, project_dir, success)
|
||||
"""
|
||||
print_header("Test 2: Memory Creation with Ollama Embeddings")
|
||||
|
||||
# Create test directories
|
||||
spec_dir = test_db_path / "test_spec"
|
||||
project_dir = test_db_path / "test_project"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" Spec dir: {spec_dir}")
|
||||
print(f" Project dir: {project_dir}")
|
||||
print(f" Database path: {test_db_path}")
|
||||
print()
|
||||
|
||||
# Override database path for testing
|
||||
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
|
||||
os.environ["GRAPHITI_DATABASE"] = "test_ollama_memory"
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import GraphitiMemory", f"Failed: {e}", False)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
# Step 1: Initialize GraphitiMemory
|
||||
print_step(1, "Initializing GraphitiMemory")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
print(f" Is enabled: {memory.is_enabled}")
|
||||
print(f" Group ID: {memory.group_id}")
|
||||
|
||||
if not memory.is_enabled:
|
||||
print_result(
|
||||
"GraphitiMemory",
|
||||
"Not enabled - check GRAPHITI_ENABLED=true",
|
||||
False,
|
||||
)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
init_result = await memory.initialize()
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed to initialize", False)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
# Step 2: Save session insights
|
||||
print_step(2, "Saving session insights")
|
||||
|
||||
session_insights = {
|
||||
"subtasks_completed": ["implement-oauth-login", "add-jwt-validation"],
|
||||
"discoveries": {
|
||||
"files_understood": {
|
||||
"auth/oauth.py": "OAuth 2.0 flow implementation with Google/GitHub",
|
||||
"auth/jwt.py": "JWT token generation and validation utilities",
|
||||
},
|
||||
"patterns_found": [
|
||||
"Pattern: Use refresh tokens for long-lived sessions",
|
||||
"Pattern: Store tokens in httpOnly cookies for security",
|
||||
],
|
||||
"gotchas_encountered": [
|
||||
"Gotcha: Always validate JWT signature on server side",
|
||||
"Gotcha: OAuth state parameter prevents CSRF attacks",
|
||||
],
|
||||
},
|
||||
"what_worked": [
|
||||
"Using PyJWT for token handling",
|
||||
"Separating OAuth providers into individual modules",
|
||||
],
|
||||
"what_failed": [],
|
||||
"recommendations_for_next_session": [
|
||||
"Consider adding refresh token rotation",
|
||||
"Add rate limiting to auth endpoints",
|
||||
],
|
||||
}
|
||||
|
||||
save_result = await memory.save_session_insights(
|
||||
session_num=1, insights=session_insights
|
||||
)
|
||||
print_result(
|
||||
"save_session_insights", "SUCCESS" if save_result else "FAILED", save_result
|
||||
)
|
||||
|
||||
# Step 3: Save patterns
|
||||
print_step(3, "Saving code patterns")
|
||||
|
||||
patterns = [
|
||||
"OAuth implementation uses authorization code flow for web apps",
|
||||
"JWT tokens include user ID, roles, and expiration in payload",
|
||||
"Token refresh happens automatically when access token expires",
|
||||
]
|
||||
|
||||
for i, pattern in enumerate(patterns):
|
||||
result = await memory.save_pattern(pattern)
|
||||
print_result(f"save_pattern {i + 1}", "SUCCESS" if result else "FAILED", result)
|
||||
|
||||
# Step 4: Save gotchas
|
||||
print_step(4, "Saving gotchas (pitfalls)")
|
||||
|
||||
gotchas = [
|
||||
"Never store config values in frontend code or files checked into git",
|
||||
"API redirect URIs must exactly match the registered URIs",
|
||||
"Cache expiration times should be short for performance (15 min default)",
|
||||
]
|
||||
|
||||
for i, gotcha in enumerate(gotchas):
|
||||
result = await memory.save_gotcha(gotcha)
|
||||
print_result(f"save_gotcha {i + 1}", "SUCCESS" if result else "FAILED", result)
|
||||
|
||||
# Step 5: Save codebase discoveries
|
||||
print_step(5, "Saving codebase discoveries")
|
||||
|
||||
discoveries = {
|
||||
"api/routes/users.py": "User management API endpoints (list, create, update)",
|
||||
"middleware/logging.py": "Request logging middleware for all routes",
|
||||
"models/user.py": "User model with profile data and role management",
|
||||
"services/notifications.py": "Notification service integrations (email, SMS, push)",
|
||||
}
|
||||
|
||||
discovery_result = await memory.save_codebase_discoveries(discoveries)
|
||||
print_result(
|
||||
"save_codebase_discoveries",
|
||||
"SUCCESS" if discovery_result else "FAILED",
|
||||
discovery_result,
|
||||
)
|
||||
|
||||
# Brief wait for embedding processing
|
||||
print()
|
||||
print_info("Waiting 3 seconds for embedding processing...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
await memory.close()
|
||||
|
||||
print()
|
||||
print_result("Memory Creation", "All memories saved successfully", True)
|
||||
return spec_dir, project_dir, True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 3: Memory Retrieval with Semantic Search
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_memory_retrieval(spec_dir: Path, project_dir: Path) -> bool:
|
||||
"""
|
||||
Test retrieving memories using semantic search with Ollama embeddings.
|
||||
|
||||
This validates that saved memories can be found via semantic similarity.
|
||||
"""
|
||||
print_header("Test 3: Memory Retrieval with Semantic Search")
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import GraphitiMemory", f"Failed: {e}", False)
|
||||
return False
|
||||
|
||||
# Step 1: Initialize memory (reconnect)
|
||||
print_step(1, "Reconnecting to GraphitiMemory")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed to reconnect", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "Reconnected successfully", True)
|
||||
|
||||
# Step 2: Semantic search for API-related content
|
||||
print_step(2, "Searching for API-related memories")
|
||||
|
||||
api_query = "How do the API endpoints work in this project?"
|
||||
results = await memory.get_relevant_context(api_query, num_results=5)
|
||||
|
||||
print(f" Query: '{api_query}'")
|
||||
print(f" Found {len(results)} results:")
|
||||
|
||||
api_found = False
|
||||
for i, result in enumerate(results):
|
||||
content = result.get("content", "")[:100]
|
||||
result_type = result.get("type", "unknown")
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
|
||||
if "api" in content.lower() or "routes" in content.lower():
|
||||
api_found = True
|
||||
|
||||
if api_found:
|
||||
print_result("API search", "Found API-related content", True)
|
||||
else:
|
||||
print_info("API content may not be in top results - checking other queries")
|
||||
|
||||
# Step 3: Search for middleware-related content
|
||||
print_step(3, "Searching for middleware patterns")
|
||||
|
||||
middleware_query = "middleware and request handling best practices"
|
||||
results = await memory.get_relevant_context(middleware_query, num_results=5)
|
||||
|
||||
print(f" Query: '{middleware_query}'")
|
||||
print(f" Found {len(results)} results:")
|
||||
|
||||
middleware_found = False
|
||||
for i, result in enumerate(results):
|
||||
content = result.get("content", "")[:100]
|
||||
result_type = result.get("type", "unknown")
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
|
||||
if "middleware" in content.lower() or "routes" in content.lower():
|
||||
middleware_found = True
|
||||
|
||||
print_result(
|
||||
"Middleware search",
|
||||
"Found middleware-related content" if middleware_found else "No direct matches",
|
||||
middleware_found or len(results) > 0,
|
||||
)
|
||||
|
||||
# Step 4: Get session history
|
||||
print_step(4, "Retrieving session history")
|
||||
|
||||
history = await memory.get_session_history(limit=3)
|
||||
print(f" Found {len(history)} session records:")
|
||||
|
||||
for i, session in enumerate(history):
|
||||
session_num = session.get("session_number", "?")
|
||||
subtasks = session.get("subtasks_completed", [])
|
||||
print(f" Session {session_num}: {len(subtasks)} subtasks completed")
|
||||
for subtask in subtasks[:3]:
|
||||
print(f" - {subtask}")
|
||||
|
||||
print_result(
|
||||
"Session history", f"Retrieved {len(history)} sessions", len(history) > 0
|
||||
)
|
||||
|
||||
# Step 5: Get status summary
|
||||
print_step(5, "Memory status summary")
|
||||
|
||||
status = memory.get_status_summary()
|
||||
for key, value in status.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
await memory.close()
|
||||
|
||||
print()
|
||||
all_passed = len(results) > 0 and len(history) > 0
|
||||
print_result(
|
||||
"Memory Retrieval",
|
||||
"All retrieval tests passed" if all_passed else "Some tests had issues",
|
||||
all_passed,
|
||||
)
|
||||
return all_passed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 4: Full Create → Store → Retrieve Cycle
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_full_cycle(test_db_path: Path) -> bool:
|
||||
"""
|
||||
Test the complete memory lifecycle:
|
||||
1. Create unique test data
|
||||
2. Store in graph database with Ollama embeddings
|
||||
3. Search and retrieve via semantic similarity
|
||||
4. Verify retrieved data matches what was stored
|
||||
"""
|
||||
print_header("Test 4: Full Create-Store-Retrieve Cycle")
|
||||
|
||||
# Create fresh test directories
|
||||
spec_dir = test_db_path / "cycle_test_spec"
|
||||
project_dir = test_db_path / "cycle_test_project"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Override database path for testing
|
||||
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
|
||||
os.environ["GRAPHITI_DATABASE"] = "test_full_cycle"
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import", f"Failed: {e}", False)
|
||||
return False
|
||||
|
||||
# Step 1: Create unique test content
|
||||
print_step(1, "Creating unique test content")
|
||||
|
||||
unique_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_pattern = (
|
||||
f"Unique pattern {unique_id}: Use dependency injection for database connections"
|
||||
)
|
||||
unique_gotcha = f"Unique gotcha {unique_id}: Always close database connections in finally blocks"
|
||||
|
||||
print(f" Unique ID: {unique_id}")
|
||||
print(f" Pattern: {unique_pattern[:60]}...")
|
||||
print(f" Gotcha: {unique_gotcha[:60]}...")
|
||||
|
||||
# Step 2: Store the content
|
||||
print_step(2, "Storing content in memory system")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
pattern_result = await memory.save_pattern(unique_pattern)
|
||||
print_result(
|
||||
"save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result
|
||||
)
|
||||
|
||||
gotcha_result = await memory.save_gotcha(unique_gotcha)
|
||||
print_result("save_gotcha", "SUCCESS" if gotcha_result else "FAILED", gotcha_result)
|
||||
|
||||
# Wait for embedding processing
|
||||
print()
|
||||
print_info("Waiting 4 seconds for embedding processing and indexing...")
|
||||
await asyncio.sleep(4)
|
||||
|
||||
# Step 3: Search for the unique content
|
||||
print_step(3, "Searching for unique content")
|
||||
|
||||
# Search for the pattern
|
||||
pattern_query = "dependency injection database connections"
|
||||
pattern_results = await memory.get_relevant_context(pattern_query, num_results=5)
|
||||
|
||||
print(f" Query: '{pattern_query}'")
|
||||
print(f" Found {len(pattern_results)} results")
|
||||
|
||||
pattern_found = False
|
||||
for result in pattern_results:
|
||||
content = result.get("content", "")
|
||||
if unique_id in content:
|
||||
pattern_found = True
|
||||
print(f" MATCH: {content[:80]}...")
|
||||
|
||||
print_result(
|
||||
"Pattern retrieval",
|
||||
f"Found unique pattern (ID: {unique_id})"
|
||||
if pattern_found
|
||||
else "Unique pattern not in top results",
|
||||
pattern_found,
|
||||
)
|
||||
|
||||
# Search for the gotcha
|
||||
gotcha_query = "database connection cleanup finally block"
|
||||
gotcha_results = await memory.get_relevant_context(gotcha_query, num_results=5)
|
||||
|
||||
print(f" Query: '{gotcha_query}'")
|
||||
print(f" Found {len(gotcha_results)} results")
|
||||
|
||||
gotcha_found = False
|
||||
for result in gotcha_results:
|
||||
content = result.get("content", "")
|
||||
if unique_id in content:
|
||||
gotcha_found = True
|
||||
print(f" MATCH: {content[:80]}...")
|
||||
|
||||
print_result(
|
||||
"Gotcha retrieval",
|
||||
f"Found unique gotcha (ID: {unique_id})"
|
||||
if gotcha_found
|
||||
else "Unique gotcha not in top results",
|
||||
gotcha_found,
|
||||
)
|
||||
|
||||
# Step 4: Verify semantic similarity works
|
||||
print_step(4, "Verifying semantic similarity")
|
||||
|
||||
# Search with semantically similar but different wording
|
||||
alt_query = "closing connections properly in error handling"
|
||||
alt_results = await memory.get_relevant_context(alt_query, num_results=3)
|
||||
|
||||
print(f" Alternative query: '{alt_query}'")
|
||||
print(f" Found {len(alt_results)} semantically similar results:")
|
||||
|
||||
for i, result in enumerate(alt_results):
|
||||
content = result.get("content", "")[:80]
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. (score: {score:.4f}) {content}...")
|
||||
|
||||
semantic_works = len(alt_results) > 0
|
||||
print_result(
|
||||
"Semantic similarity",
|
||||
"Working - found related content" if semantic_works else "No results",
|
||||
semantic_works,
|
||||
)
|
||||
|
||||
await memory.close()
|
||||
|
||||
# Summary
|
||||
print()
|
||||
cycle_passed = (
|
||||
pattern_result
|
||||
and gotcha_result
|
||||
and (pattern_found or gotcha_found or len(alt_results) > 0)
|
||||
)
|
||||
print_result(
|
||||
"Full Cycle Test",
|
||||
"Create-Store-Retrieve cycle verified"
|
||||
if cycle_passed
|
||||
else "Some steps had issues",
|
||||
cycle_passed,
|
||||
)
|
||||
|
||||
return cycle_passed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run Ollama embedding memory tests."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Ollama Embedding Memory Integration"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
choices=["all", "embeddings", "create", "retrieve", "full-cycle"],
|
||||
default="all",
|
||||
help="Which test to run",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-db",
|
||||
action="store_true",
|
||||
help="Keep test database after completion (default: cleanup)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" OLLAMA EMBEDDING MEMORY TEST SUITE")
|
||||
print("=" * 70)
|
||||
|
||||
# Configuration check
|
||||
print_header("Configuration Check")
|
||||
|
||||
config_items = {
|
||||
"GRAPHITI_ENABLED": os.environ.get("GRAPHITI_ENABLED", ""),
|
||||
"GRAPHITI_LLM_PROVIDER": os.environ.get("GRAPHITI_LLM_PROVIDER", ""),
|
||||
"GRAPHITI_EMBEDDER_PROVIDER": os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", ""),
|
||||
"OLLAMA_LLM_MODEL": os.environ.get("OLLAMA_LLM_MODEL", ""),
|
||||
"OLLAMA_EMBEDDING_MODEL": os.environ.get("OLLAMA_EMBEDDING_MODEL", ""),
|
||||
"OLLAMA_EMBEDDING_DIM": os.environ.get("OLLAMA_EMBEDDING_DIM", ""),
|
||||
"OLLAMA_BASE_URL": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434"),
|
||||
"OPENAI_API_KEY": "(set)"
|
||||
if os.environ.get("OPENAI_API_KEY")
|
||||
else "(not set - needed for reranker)",
|
||||
}
|
||||
|
||||
all_configured = True
|
||||
required_keys = [
|
||||
"GRAPHITI_ENABLED",
|
||||
"GRAPHITI_LLM_PROVIDER",
|
||||
"GRAPHITI_EMBEDDER_PROVIDER",
|
||||
"OLLAMA_LLM_MODEL",
|
||||
"OLLAMA_EMBEDDING_MODEL",
|
||||
]
|
||||
|
||||
for key, value in config_items.items():
|
||||
is_optional = key in [
|
||||
"OLLAMA_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OLLAMA_EMBEDDING_DIM",
|
||||
]
|
||||
is_set = bool(value) if not is_optional else True
|
||||
display_value = value or "(not set)"
|
||||
if key == "OPENAI_API_KEY":
|
||||
display_value = value # Already formatted above
|
||||
is_set = True # Optional for testing
|
||||
print_result(key, display_value, is_set)
|
||||
if key in required_keys and not bool(os.environ.get(key)):
|
||||
all_configured = False
|
||||
|
||||
if not all_configured:
|
||||
print()
|
||||
print(" Missing required configuration. Please set:")
|
||||
print(" export GRAPHITI_ENABLED=true")
|
||||
print(" export GRAPHITI_LLM_PROVIDER=ollama")
|
||||
print(" export GRAPHITI_EMBEDDER_PROVIDER=ollama")
|
||||
print(" export OLLAMA_LLM_MODEL=deepseek-r1:7b")
|
||||
print(" export OLLAMA_EMBEDDING_MODEL=embeddinggemma")
|
||||
print(" export OLLAMA_EMBEDDING_DIM=768")
|
||||
print(" export OPENAI_API_KEY=dummy # For graphiti-core reranker")
|
||||
print()
|
||||
return
|
||||
|
||||
# Check LadybugDB
|
||||
if not apply_ladybug_monkeypatch():
|
||||
print()
|
||||
print_result("LadybugDB", "Not installed - pip install real-ladybug", False)
|
||||
return
|
||||
|
||||
print_result("LadybugDB", "Installed", True)
|
||||
|
||||
# Create temp directory for test database
|
||||
test_db_path = Path(tempfile.mkdtemp(prefix="ollama_memory_test_"))
|
||||
print()
|
||||
print_info(f"Test database: {test_db_path}")
|
||||
|
||||
# Run tests
|
||||
test = args.test
|
||||
results = {}
|
||||
|
||||
try:
|
||||
if test in ["all", "embeddings"]:
|
||||
results["embeddings"] = await test_ollama_embeddings()
|
||||
|
||||
spec_dir = None
|
||||
project_dir = None
|
||||
|
||||
if test in ["all", "create"]:
|
||||
spec_dir, project_dir, results["create"] = await test_memory_creation(
|
||||
test_db_path
|
||||
)
|
||||
|
||||
if test in ["all", "retrieve"]:
|
||||
if spec_dir and project_dir:
|
||||
results["retrieve"] = await test_memory_retrieval(spec_dir, project_dir)
|
||||
else:
|
||||
print_info(
|
||||
"Skipping retrieve test - no spec/project dir from create test"
|
||||
)
|
||||
|
||||
if test in ["all", "full-cycle"]:
|
||||
results["full-cycle"] = await test_full_cycle(test_db_path)
|
||||
|
||||
finally:
|
||||
# Cleanup unless --keep-db specified
|
||||
if not args.keep_db and test_db_path.exists():
|
||||
print()
|
||||
print_info(f"Cleaning up test database: {test_db_path}")
|
||||
shutil.rmtree(test_db_path, ignore_errors=True)
|
||||
|
||||
# Summary
|
||||
print_header("TEST SUMMARY")
|
||||
|
||||
all_passed = True
|
||||
for test_name, passed in results.items():
|
||||
status = "PASSED" if passed else "FAILED"
|
||||
print(f" {test_name}: {status}")
|
||||
if not passed:
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
if all_passed:
|
||||
print(" All tests PASSED!")
|
||||
print()
|
||||
print(" The memory system is working correctly with Ollama embeddings.")
|
||||
print(" Memories can be created and retrieved using semantic search.")
|
||||
else:
|
||||
print(" Some tests FAILED. Check the output above for details.")
|
||||
print()
|
||||
print(" Common issues:")
|
||||
print(" - Ollama not running: ollama serve")
|
||||
print(" - Model not pulled: ollama pull embeddinggemma")
|
||||
print(" - Wrong dimension: Update OLLAMA_EMBEDDING_DIM to match model")
|
||||
|
||||
print()
|
||||
print(" Commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py")
|
||||
print()
|
||||
print(" # Run specific test:")
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
|
||||
)
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
|
||||
)
|
||||
print()
|
||||
print(" # Keep database for inspection:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -43,9 +43,9 @@ def create_claude_resolver() -> AIResolver:
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.simple_client import create_simple_client
|
||||
except ImportError:
|
||||
logger.warning("claude_agent_sdk not installed, AI resolution unavailable")
|
||||
logger.warning("core.simple_client not available, AI resolution unavailable")
|
||||
return AIResolver()
|
||||
|
||||
def call_claude(system: str, user: str) -> str:
|
||||
@@ -53,13 +53,10 @@ def create_claude_resolver() -> AIResolver:
|
||||
|
||||
async def _run_merge() -> str:
|
||||
# Create a minimal client for merge resolution
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="sonnet",
|
||||
system_prompt=system,
|
||||
allowed_tools=[], # No tools needed for merge
|
||||
max_turns=1,
|
||||
)
|
||||
client = create_simple_client(
|
||||
agent_type="merge_resolver",
|
||||
model="sonnet",
|
||||
system_prompt=system,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -57,18 +57,33 @@ KNOWN_EMBEDDING_MODELS = {
|
||||
|
||||
# Recommended embedding models for download (shown in UI)
|
||||
RECOMMENDED_EMBEDDING_MODELS = [
|
||||
{
|
||||
"name": "qwen3-embedding:4b",
|
||||
"description": "Qwen3 4B - Balanced quality and speed",
|
||||
"size_estimate": "3.1 GB",
|
||||
"dim": 2560,
|
||||
"badge": "recommended",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:8b",
|
||||
"description": "Qwen3 8B - Best embedding quality",
|
||||
"size_estimate": "6.0 GB",
|
||||
"dim": 4096,
|
||||
"badge": "quality",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:0.6b",
|
||||
"description": "Qwen3 0.6B - Smallest and fastest",
|
||||
"size_estimate": "494 MB",
|
||||
"dim": 1024,
|
||||
"badge": "fast",
|
||||
},
|
||||
{
|
||||
"name": "embeddinggemma",
|
||||
"description": "Google's lightweight embedding model (768 dim)",
|
||||
"size_estimate": "621 MB",
|
||||
"dim": 768,
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:0.6b",
|
||||
"description": "Qwen3 small embedding model (1024 dim)",
|
||||
"size_estimate": "494 MB",
|
||||
"dim": 1024,
|
||||
},
|
||||
{
|
||||
"name": "nomic-embed-text",
|
||||
"description": "Popular general-purpose embeddings (768 dim)",
|
||||
@@ -340,50 +355,59 @@ def cmd_get_recommended_models(args) -> None:
|
||||
|
||||
|
||||
def cmd_pull_model(args) -> None:
|
||||
"""Pull (download) an Ollama model."""
|
||||
import subprocess
|
||||
|
||||
"""Pull (download) an Ollama model using the HTTP API for progress tracking."""
|
||||
model_name = args.model
|
||||
base_url = getattr(args, "base_url", None) or DEFAULT_OLLAMA_URL
|
||||
|
||||
if not model_name:
|
||||
output_error("Model name is required")
|
||||
return
|
||||
|
||||
try:
|
||||
# Run ollama pull command
|
||||
process = subprocess.Popen(
|
||||
["ollama", "pull", model_name],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
url = f"{base_url.rstrip('/')}/api/pull"
|
||||
data = json.dumps({"name": model_name}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=600) as response:
|
||||
# Ollama streams NDJSON (newline-delimited JSON) progress
|
||||
for line in response:
|
||||
try:
|
||||
progress = json.loads(line.decode("utf-8"))
|
||||
|
||||
# Emit progress as NDJSON to stderr for main process to parse
|
||||
if "completed" in progress and "total" in progress:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": progress.get("status", "downloading"),
|
||||
"completed": progress.get("completed", 0),
|
||||
"total": progress.get("total", 0),
|
||||
}
|
||||
),
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif progress.get("status") == "success":
|
||||
# Download complete
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"model": model_name,
|
||||
"status": "completed",
|
||||
"output": ["Download completed successfully"],
|
||||
},
|
||||
)
|
||||
|
||||
output_lines = []
|
||||
for line in iter(process.stdout.readline, ""):
|
||||
line = line.strip()
|
||||
if line:
|
||||
output_lines.append(line)
|
||||
# Print progress to stderr for streaming
|
||||
print(line, file=sys.stderr, flush=True)
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"model": model_name,
|
||||
"status": "completed",
|
||||
"output": output_lines,
|
||||
},
|
||||
)
|
||||
else:
|
||||
output_json(
|
||||
False, error=f"Failed to pull model: {' '.join(output_lines[-3:])}"
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
output_error("Ollama CLI not found. Please install Ollama first.")
|
||||
except urllib.error.URLError as e:
|
||||
output_error(f"Failed to connect to Ollama: {str(e)}")
|
||||
except urllib.error.HTTPError as e:
|
||||
output_error(f"Ollama API error: {e.code} - {e.reason}")
|
||||
except Exception as e:
|
||||
output_error(f"Failed to pull model: {str(e)}")
|
||||
|
||||
|
||||
@@ -112,6 +112,9 @@ class ProjectAnalyzer:
|
||||
# PHP
|
||||
"composer.json",
|
||||
"composer.lock",
|
||||
# Dart/Flutter
|
||||
"pubspec.yaml",
|
||||
"pubspec.lock",
|
||||
# Java/Kotlin/Scala
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
@@ -170,6 +173,7 @@ class ProjectAnalyzer:
|
||||
"*.ts",
|
||||
"*.go",
|
||||
"*.rs",
|
||||
"*.dart",
|
||||
"*.cs",
|
||||
"*.swift",
|
||||
"*.kt",
|
||||
|
||||
@@ -148,6 +148,21 @@ FRAMEWORK_COMMANDS: dict[str, set[str]] = {
|
||||
# Elixir/Erlang
|
||||
"phoenix": {"mix", "iex"},
|
||||
"ecto": {"mix"},
|
||||
# Dart/Flutter
|
||||
"flutter": {
|
||||
"flutter",
|
||||
"dart",
|
||||
"pub",
|
||||
"fvm", # Flutter Version Manager
|
||||
},
|
||||
"dart_frog": {"dart_frog", "dart"}, # Dart backend framework
|
||||
"serverpod": {"serverpod", "dart"}, # Dart backend framework
|
||||
"shelf": {"dart", "pub"}, # Dart HTTP server middleware
|
||||
"aqueduct": {
|
||||
"aqueduct",
|
||||
"dart",
|
||||
"pub",
|
||||
}, # Dart HTTP framework (deprecated but still used)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,12 +35,40 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
|
||||
"tsx",
|
||||
},
|
||||
"rust": {
|
||||
# Core toolchain
|
||||
"cargo",
|
||||
"rustc",
|
||||
"rustup",
|
||||
"rustfmt",
|
||||
"clippy",
|
||||
"rust-analyzer",
|
||||
# Cargo subcommand binaries
|
||||
"cargo-clippy",
|
||||
"cargo-fmt",
|
||||
"cargo-miri",
|
||||
# Common dev tools
|
||||
"cargo-watch",
|
||||
"cargo-nextest",
|
||||
"cargo-llvm-cov",
|
||||
"cargo-tarpaulin",
|
||||
# Dependency management
|
||||
"cargo-audit",
|
||||
"cargo-deny",
|
||||
"cargo-outdated",
|
||||
"cargo-edit",
|
||||
"cargo-update",
|
||||
# Build & release
|
||||
"cargo-release",
|
||||
"cargo-dist",
|
||||
"cargo-make",
|
||||
"cargo-xtask",
|
||||
# Cross-compilation & WASM
|
||||
"cross",
|
||||
"wasm-pack",
|
||||
"wasm-bindgen",
|
||||
"trunk",
|
||||
# Documentation & publishing
|
||||
"cargo-doc",
|
||||
"mdbook",
|
||||
},
|
||||
"go": {
|
||||
"go",
|
||||
@@ -144,6 +172,14 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
|
||||
"zig": {
|
||||
"zig",
|
||||
},
|
||||
"dart": {
|
||||
"dart",
|
||||
"dart2js",
|
||||
"dartanalyzer",
|
||||
"dartdoc",
|
||||
"dartfmt",
|
||||
"pub",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class FrameworkDetector:
|
||||
self.detect_python_frameworks()
|
||||
self.detect_ruby_frameworks()
|
||||
self.detect_php_frameworks()
|
||||
self.detect_dart_frameworks()
|
||||
return self.frameworks
|
||||
|
||||
def detect_nodejs_frameworks(self) -> None:
|
||||
@@ -239,3 +240,26 @@ class FrameworkDetector:
|
||||
self.frameworks.append("symfony")
|
||||
if "phpunit/phpunit" in deps:
|
||||
self.frameworks.append("phpunit")
|
||||
|
||||
def detect_dart_frameworks(self) -> None:
|
||||
"""Detect Dart/Flutter frameworks from pubspec.yaml."""
|
||||
# Read pubspec.yaml as text since we don't have a YAML parser
|
||||
content = self.parser.read_text("pubspec.yaml")
|
||||
if not content:
|
||||
return
|
||||
|
||||
content_lower = content.lower()
|
||||
|
||||
# Detect Flutter
|
||||
if "flutter:" in content_lower or "sdk: flutter" in content_lower:
|
||||
self.frameworks.append("flutter")
|
||||
|
||||
# Detect Dart backend frameworks
|
||||
if "dart_frog" in content_lower:
|
||||
self.frameworks.append("dart_frog")
|
||||
if "serverpod" in content_lower:
|
||||
self.frameworks.append("serverpod")
|
||||
if "shelf" in content_lower:
|
||||
self.frameworks.append("shelf")
|
||||
if "aqueduct" in content_lower:
|
||||
self.frameworks.append("aqueduct")
|
||||
|
||||
@@ -113,6 +113,10 @@ class StackDetector:
|
||||
if self.parser.file_exists("Package.swift", "*.swift", "**/*.swift"):
|
||||
self.stack.languages.append("swift")
|
||||
|
||||
# Dart/Flutter
|
||||
if self.parser.file_exists("pubspec.yaml", "*.dart", "**/*.dart"):
|
||||
self.stack.languages.append("dart")
|
||||
|
||||
def detect_package_managers(self) -> None:
|
||||
"""Detect package managers used."""
|
||||
# Node.js package managers
|
||||
|
||||
@@ -85,7 +85,7 @@ class ClaudeBatchAnalyzer:
|
||||
try:
|
||||
import sys
|
||||
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
import claude_agent_sdk # noqa: F401 - check availability
|
||||
|
||||
backend_path = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(backend_path))
|
||||
@@ -150,14 +150,13 @@ Respond with JSON only:
|
||||
)
|
||||
|
||||
# Using Sonnet for better analysis (still just 1 call)
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-sonnet-4-20250514",
|
||||
system_prompt="You are an expert at analyzing GitHub issues and grouping related ones. Respond ONLY with valid JSON. Do NOT use any tools.",
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
cwd=str(self.project_dir.resolve()),
|
||||
)
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="batch_analysis",
|
||||
model="claude-sonnet-4-20250514",
|
||||
system_prompt="You are an expert at analyzing GitHub issues and grouping related ones. Respond ONLY with valid JSON. Do NOT use any tools.",
|
||||
cwd=self.project_dir,
|
||||
)
|
||||
|
||||
async with client:
|
||||
|
||||
@@ -8,6 +8,7 @@ Reviews whether semantically grouped issues actually belong together.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
@@ -16,13 +17,8 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check for Claude SDK availability
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
CLAUDE_SDK_AVAILABLE = True
|
||||
except (ImportError, ValueError, SystemError):
|
||||
CLAUDE_SDK_AVAILABLE = False
|
||||
# Check for Claude SDK availability without importing (avoids unused import warning)
|
||||
CLAUDE_SDK_AVAILABLE = importlib.util.find_spec("claude_agent_sdk") is not None
|
||||
|
||||
# Default model and thinking configuration
|
||||
DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
||||
@@ -207,16 +203,14 @@ class BatchValidator:
|
||||
|
||||
try:
|
||||
# Create Claude SDK client with extended thinking
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=self.model,
|
||||
system_prompt="You are an expert at analyzing GitHub issues and determining if they should be grouped together for a combined fix.",
|
||||
allowed_tools=[], # No tools needed for this analysis
|
||||
max_turns=1,
|
||||
cwd=str(self.project_dir.resolve()),
|
||||
settings=str(settings_file.resolve()),
|
||||
max_thinking_tokens=self.thinking_budget, # Extended thinking
|
||||
)
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="batch_validation",
|
||||
model=self.model,
|
||||
system_prompt="You are an expert at analyzing GitHub issues and determining if they should be grouped together for a combined fix.",
|
||||
cwd=self.project_dir,
|
||||
max_thinking_tokens=self.thinking_budget, # Extended thinking
|
||||
)
|
||||
|
||||
async with client:
|
||||
|
||||
@@ -101,27 +101,75 @@ class AIBotComment:
|
||||
|
||||
|
||||
# Known AI code review bots and their display names
|
||||
# Organized by category for maintainability
|
||||
AI_BOT_PATTERNS: dict[str, str] = {
|
||||
# === AI Code Review Tools ===
|
||||
"coderabbitai": "CodeRabbit",
|
||||
"coderabbit-ai": "CodeRabbit",
|
||||
"coderabbit[bot]": "CodeRabbit",
|
||||
"greptile": "Greptile",
|
||||
"greptile[bot]": "Greptile",
|
||||
"greptile-ai": "Greptile",
|
||||
"greptile-apps": "Greptile",
|
||||
"cursor": "Cursor",
|
||||
"cursor-ai": "Cursor",
|
||||
"cursor[bot]": "Cursor",
|
||||
"sourcery-ai": "Sourcery",
|
||||
"sourcery-ai[bot]": "Sourcery",
|
||||
"sourcery-ai-bot": "Sourcery",
|
||||
"codiumai": "Qodo",
|
||||
"codium-ai[bot]": "Qodo",
|
||||
"codiumai-agent": "Qodo",
|
||||
"qodo-merge-bot": "Qodo",
|
||||
# === AI Coding Assistants ===
|
||||
"copilot": "GitHub Copilot",
|
||||
"copilot[bot]": "GitHub Copilot",
|
||||
"copilot-swe-agent[bot]": "GitHub Copilot",
|
||||
"sweep-ai[bot]": "Sweep AI",
|
||||
"sweep-nightly[bot]": "Sweep AI",
|
||||
"sweep-canary[bot]": "Sweep AI",
|
||||
"bitoagent": "Bito AI",
|
||||
"codeium-ai-superpowers": "Codeium",
|
||||
"devin-ai-integration": "Devin AI",
|
||||
# === GitHub Native Bots ===
|
||||
"github-actions": "GitHub Actions",
|
||||
"github-actions[bot]": "GitHub Actions",
|
||||
"deepsource-autofix": "DeepSource",
|
||||
"deepsource-autofix[bot]": "DeepSource",
|
||||
"github-advanced-security": "GitHub Advanced Security",
|
||||
"github-advanced-security[bot]": "GitHub Advanced Security",
|
||||
"dependabot": "Dependabot",
|
||||
"dependabot[bot]": "Dependabot",
|
||||
"github-merge-queue[bot]": "GitHub Merge Queue",
|
||||
# === Code Quality & Static Analysis ===
|
||||
"sonarcloud": "SonarCloud",
|
||||
"sonarcloud[bot]": "SonarCloud",
|
||||
"deepsource-autofix": "DeepSource",
|
||||
"deepsource-autofix[bot]": "DeepSource",
|
||||
"deepsourcebot": "DeepSource",
|
||||
"codeclimate[bot]": "CodeClimate",
|
||||
"codefactor-io[bot]": "CodeFactor",
|
||||
"codacy[bot]": "Codacy",
|
||||
# === Security Scanning ===
|
||||
"snyk-bot": "Snyk",
|
||||
"snyk[bot]": "Snyk",
|
||||
"snyk-security-bot": "Snyk",
|
||||
"gitguardian[bot]": "GitGuardian",
|
||||
"semgrep-app[bot]": "Semgrep",
|
||||
"semgrep-bot": "Semgrep",
|
||||
# === Code Coverage ===
|
||||
"codecov[bot]": "Codecov",
|
||||
"codecov-commenter": "Codecov",
|
||||
"coveralls": "Coveralls",
|
||||
"coveralls[bot]": "Coveralls",
|
||||
# === Dependency Management ===
|
||||
"renovate[bot]": "Renovate",
|
||||
"renovate-bot": "Renovate",
|
||||
"self-hosted-renovate[bot]": "Renovate",
|
||||
# === PR Automation ===
|
||||
"mergify[bot]": "Mergify",
|
||||
"imgbotapp": "Imgbot",
|
||||
"imgbot[bot]": "Imgbot",
|
||||
"allstar[bot]": "Allstar",
|
||||
"percy[bot]": "Percy",
|
||||
}
|
||||
|
||||
|
||||
@@ -1013,6 +1061,15 @@ class FollowupContextGatherer:
|
||||
print(f"[Followup] Error fetching comments: {e}", flush=True)
|
||||
comments = {"review_comments": [], "issue_comments": []}
|
||||
|
||||
# Get formal PR reviews since last review (from Cursor, CodeRabbit, etc.)
|
||||
try:
|
||||
pr_reviews = await self.gh_client.get_reviews_since(
|
||||
self.pr_number, self.previous_review.reviewed_at
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Followup] Error fetching PR reviews: {e}", flush=True)
|
||||
pr_reviews = []
|
||||
|
||||
# Separate AI bot comments from contributor comments
|
||||
ai_comments = []
|
||||
contributor_comments = []
|
||||
@@ -1035,8 +1092,33 @@ class FollowupContextGatherer:
|
||||
else:
|
||||
contributor_comments.append(comment)
|
||||
|
||||
# Separate AI bot reviews from contributor reviews
|
||||
ai_reviews = []
|
||||
contributor_reviews = []
|
||||
|
||||
for review in pr_reviews:
|
||||
author = ""
|
||||
if isinstance(review.get("user"), dict):
|
||||
author = review["user"].get("login", "").lower()
|
||||
|
||||
is_ai_bot = any(pattern in author for pattern in AI_BOT_PATTERNS.keys())
|
||||
|
||||
if is_ai_bot:
|
||||
ai_reviews.append(review)
|
||||
else:
|
||||
contributor_reviews.append(review)
|
||||
|
||||
# Combine AI comments and reviews for reporting
|
||||
total_ai_feedback = len(ai_comments) + len(ai_reviews)
|
||||
total_contributor_feedback = len(contributor_comments) + len(
|
||||
contributor_reviews
|
||||
)
|
||||
|
||||
print(
|
||||
f"[Followup] Found {len(contributor_comments)} contributor comments, {len(ai_comments)} AI comments",
|
||||
f"[Followup] Found {total_contributor_feedback} contributor feedback "
|
||||
f"({len(contributor_comments)} comments, {len(contributor_reviews)} reviews), "
|
||||
f"{total_ai_feedback} AI feedback "
|
||||
f"({len(ai_comments)} comments, {len(ai_reviews)} reviews)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
@@ -1048,6 +1130,8 @@ class FollowupContextGatherer:
|
||||
commits_since_review=commits,
|
||||
files_changed_since_review=files_changed,
|
||||
diff_since_review=diff_since_review,
|
||||
contributor_comments_since_review=contributor_comments,
|
||||
contributor_comments_since_review=contributor_comments
|
||||
+ contributor_reviews,
|
||||
ai_bot_comments_since_review=ai_comments,
|
||||
pr_reviews_since_review=ai_reviews,
|
||||
)
|
||||
|
||||
@@ -695,6 +695,73 @@ class GHClient:
|
||||
"issue_comments": issue_comments,
|
||||
}
|
||||
|
||||
async def get_reviews_since(
|
||||
self, pr_number: int, since_timestamp: str
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get all PR reviews (formal review submissions) since a timestamp.
|
||||
|
||||
This fetches formal reviews submitted via the GitHub review mechanism,
|
||||
which is different from review comments (inline comments on files).
|
||||
|
||||
Reviews from AI tools like Cursor, CodeRabbit, Greptile etc. are
|
||||
submitted as formal reviews with body text containing their findings.
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
since_timestamp: ISO timestamp to filter from (e.g., "2025-12-25T10:30:00Z")
|
||||
|
||||
Returns:
|
||||
List of review objects with fields:
|
||||
- id: Review ID
|
||||
- user: User who submitted the review
|
||||
- body: Review body text (contains AI findings)
|
||||
- state: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING
|
||||
- submitted_at: When the review was submitted
|
||||
- commit_id: Commit SHA the review was made on
|
||||
"""
|
||||
# Fetch all reviews for the PR
|
||||
# Note: The reviews endpoint doesn't support 'since' parameter,
|
||||
# so we fetch all and filter client-side
|
||||
reviews_endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews"
|
||||
reviews_args = ["api", "--method", "GET", reviews_endpoint]
|
||||
reviews_result = await self.run(reviews_args, raise_on_error=False)
|
||||
|
||||
reviews = []
|
||||
if reviews_result.returncode == 0:
|
||||
try:
|
||||
all_reviews = json.loads(reviews_result.stdout)
|
||||
# Filter reviews submitted after the timestamp
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Parse since_timestamp, handling both naive and aware formats
|
||||
since_dt = datetime.fromisoformat(
|
||||
since_timestamp.replace("Z", "+00:00")
|
||||
)
|
||||
# Ensure since_dt is timezone-aware (assume UTC if naive)
|
||||
if since_dt.tzinfo is None:
|
||||
since_dt = since_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
for review in all_reviews:
|
||||
submitted_at = review.get("submitted_at", "")
|
||||
if submitted_at:
|
||||
try:
|
||||
review_dt = datetime.fromisoformat(
|
||||
submitted_at.replace("Z", "+00:00")
|
||||
)
|
||||
# Ensure review_dt is also timezone-aware
|
||||
if review_dt.tzinfo is None:
|
||||
review_dt = review_dt.replace(tzinfo=timezone.utc)
|
||||
if review_dt > since_dt:
|
||||
reviews.append(review)
|
||||
except ValueError:
|
||||
# If we can't parse the date, include the review
|
||||
reviews.append(review)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse reviews for PR #{pr_number}")
|
||||
|
||||
return reviews
|
||||
|
||||
async def get_pr_head_sha(self, pr_number: int) -> str | None:
|
||||
"""
|
||||
Get the current HEAD SHA of a PR.
|
||||
|
||||
@@ -525,6 +525,10 @@ class FollowupReviewContext:
|
||||
contributor_comments_since_review: list[dict] = field(default_factory=list)
|
||||
ai_bot_comments_since_review: list[dict] = field(default_factory=list)
|
||||
|
||||
# PR reviews since last review (formal review submissions from Cursor, CodeRabbit, etc.)
|
||||
# These are different from comments - they're full review submissions with body text
|
||||
pr_reviews_since_review: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriageResult:
|
||||
|
||||
@@ -608,6 +608,16 @@ class FollowupReviewer:
|
||||
]
|
||||
)
|
||||
|
||||
# Format PR reviews (formal review submissions from Cursor, CodeRabbit, etc.)
|
||||
# These often contain detailed findings in the body, so we include more content
|
||||
pr_reviews_text = "\n\n".join(
|
||||
[
|
||||
f"**@{r.get('user', {}).get('login', 'unknown')}** ({r.get('state', 'COMMENTED')}):\n{r.get('body', '')[:2000]}"
|
||||
for r in context.pr_reviews_since_review
|
||||
if r.get("body", "").strip() # Only include reviews with body content
|
||||
]
|
||||
)
|
||||
|
||||
# Build the full message
|
||||
user_message = f"""
|
||||
{prompt_template}
|
||||
@@ -640,8 +650,16 @@ class FollowupReviewer:
|
||||
### AI BOT COMMENTS SINCE LAST REVIEW:
|
||||
{ai_comments_text if ai_comments_text else "No AI bot comments."}
|
||||
|
||||
### PR REVIEWS SINCE LAST REVIEW (Cursor, CodeRabbit, etc.):
|
||||
{pr_reviews_text if pr_reviews_text else "No PR reviews since last review."}
|
||||
|
||||
---
|
||||
|
||||
**IMPORTANT**: Pay special attention to the PR REVIEWS section above. These are formal code reviews from AI tools like Cursor, CodeRabbit, Greptile, etc. that may have identified issues in the recent changes. You should:
|
||||
1. Consider their findings when evaluating the code
|
||||
2. Create new findings for valid issues they identified that haven't been addressed
|
||||
3. Note if the recent commits addressed concerns raised in these reviews
|
||||
|
||||
Analyze this follow-up review context and provide your structured response.
|
||||
"""
|
||||
|
||||
@@ -649,8 +667,11 @@ Analyze this follow-up review context and provide your structured response.
|
||||
# Use Claude Agent SDK query() with structured outputs
|
||||
# Reference: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
from claude_agent_sdk import ClaudeAgentOptions, query
|
||||
from phase_config import get_thinking_budget
|
||||
|
||||
model = self.config.model or "claude-sonnet-4-5-20250929"
|
||||
thinking_level = self.config.thinking_level or "medium"
|
||||
thinking_budget = get_thinking_budget(thinking_level)
|
||||
|
||||
# Debug: Log the schema being sent
|
||||
schema = FollowupReviewResponse.model_json_schema()
|
||||
@@ -668,7 +689,7 @@ Analyze this follow-up review context and provide your structured response.
|
||||
system_prompt="You are a code review assistant. Analyze the provided context and provide structured feedback.",
|
||||
allowed_tools=[],
|
||||
max_turns=2, # Need 2 turns for structured output tool call
|
||||
max_thinking_tokens=2048,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": schema,
|
||||
@@ -702,6 +723,23 @@ Analyze this follow-up review context and provide your structured response.
|
||||
)
|
||||
return self._convert_structured_to_internal(result)
|
||||
|
||||
# Also check for direct structured_output attribute (SDK validated JSON)
|
||||
if (
|
||||
hasattr(message, "structured_output")
|
||||
and message.structured_output
|
||||
):
|
||||
logger.info(
|
||||
"[Followup] Found structured_output attribute on message"
|
||||
)
|
||||
print(
|
||||
"[Followup] Using SDK structured output (direct attribute)",
|
||||
flush=True,
|
||||
)
|
||||
result = FollowupReviewResponse.model_validate(
|
||||
message.structured_output
|
||||
)
|
||||
return self._convert_structured_to_internal(result)
|
||||
|
||||
# Handle ResultMessage for errors
|
||||
if msg_type == "ResultMessage":
|
||||
subtype = getattr(message, "subtype", None)
|
||||
|
||||
@@ -21,6 +21,7 @@ DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget
|
||||
from ..context_gatherer import PRContext
|
||||
from ..models import (
|
||||
GitHubRunnerConfig,
|
||||
@@ -51,6 +52,7 @@ except (ImportError, ValueError, SystemError):
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from phase_config import get_thinking_budget
|
||||
from services.pydantic_models import OrchestratorReviewResponse
|
||||
from services.review_tools import (
|
||||
check_coverage,
|
||||
@@ -178,19 +180,29 @@ class OrchestratorReviewer:
|
||||
# Build orchestrator prompt with tool definitions
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
|
||||
# Create Opus 4.5 client with extended thinking
|
||||
# Create client with user-configured model and thinking level
|
||||
project_root = (
|
||||
self.project_dir.parent.parent
|
||||
if self.project_dir.name == "backend"
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
# Use model and thinking level from config (user settings)
|
||||
model = self.config.model or "claude-sonnet-4-5-20250929"
|
||||
thinking_level = self.config.thinking_level or "medium"
|
||||
thinking_budget = get_thinking_budget(thinking_level)
|
||||
|
||||
logger.info(
|
||||
f"[Orchestrator] Using model={model}, thinking_level={thinking_level}, "
|
||||
f"thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model="claude-opus-4-5-20251101", # Opus for strategic thinking
|
||||
model=model,
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
max_thinking_tokens=10000, # High budget for strategy
|
||||
max_thinking_tokens=thinking_budget,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": OrchestratorReviewResponse.model_json_schema(),
|
||||
@@ -218,7 +230,7 @@ class OrchestratorReviewer:
|
||||
await client.query(prompt)
|
||||
|
||||
print(
|
||||
"[Orchestrator] Waiting for LLM response (Opus 4.5 with extended thinking)...",
|
||||
f"[Orchestrator] Waiting for LLM response ({model} with {thinking_level} thinking)...",
|
||||
flush=True,
|
||||
)
|
||||
if DEBUG_MODE:
|
||||
|
||||
@@ -9,8 +9,8 @@ summarized and passed as context to subsequent phases.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from core.auth import require_auth_token
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
|
||||
async def summarize_phase_output(
|
||||
@@ -58,18 +58,14 @@ Be concise and use bullet points. Skip boilerplate and meta-commentary.
|
||||
## Summary:
|
||||
"""
|
||||
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are a concise technical summarizer. Extract only the most "
|
||||
"critical information from phase outputs. Use bullet points. "
|
||||
"Focus on decisions, discoveries, and actionable insights."
|
||||
),
|
||||
allowed_tools=[], # No tools needed for summarization
|
||||
max_turns=1,
|
||||
env=get_sdk_env_vars(),
|
||||
)
|
||||
client = create_simple_client(
|
||||
agent_type="spec_compaction",
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are a concise technical summarizer. Extract only the most "
|
||||
"critical information from phase outputs. Use bullet points. "
|
||||
"Focus on decisions, discoveries, and actionable insights."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -92,6 +92,6 @@ export default tseslint.config(
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['out/**', 'dist/**', '.eslintrc.cjs', 'eslint.config.mjs', 'node_modules/**']
|
||||
ignores: ['out/**', 'dist/**', '.eslintrc.cjs', 'eslint.config.mjs', 'node_modules/**', 'python-runtime/**']
|
||||
}
|
||||
);
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.2",
|
||||
"version": "2.7.2-beta.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.2",
|
||||
"version": "2.7.2-beta.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -39,6 +39,7 @@
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"i18next": "^25.7.3",
|
||||
"lucide-react": "^0.560.0",
|
||||
@@ -6747,6 +6748,15 @@
|
||||
"electron-winstaller": "5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-log": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
|
||||
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "26.0.11",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.0.11.tgz",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"i18next": "^25.7.3",
|
||||
"lucide-react": "^0.560.0",
|
||||
@@ -158,6 +159,10 @@
|
||||
"from": "python-runtime/${os}-${arch}/python",
|
||||
"to": "python"
|
||||
},
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/site-packages",
|
||||
"to": "python-site-packages"
|
||||
},
|
||||
{
|
||||
"from": "../backend",
|
||||
"to": "backend",
|
||||
|
||||
@@ -25,6 +25,97 @@ const nodeCrypto = require('crypto');
|
||||
// Python version to bundle (must be 3.10+ for claude-agent-sdk, 3.12+ for full Graphiti support)
|
||||
const PYTHON_VERSION = '3.12.8';
|
||||
|
||||
// Patterns for files/directories to strip from site-packages to reduce size
|
||||
// These are safe to remove - Python doesn't need them at runtime
|
||||
const STRIP_PATTERNS = {
|
||||
// Directories to remove entirely
|
||||
dirs: [
|
||||
'__pycache__',
|
||||
'tests',
|
||||
'test',
|
||||
'testing',
|
||||
'docs',
|
||||
'doc',
|
||||
'examples',
|
||||
'example',
|
||||
'benchmarks',
|
||||
'benchmark',
|
||||
'.git',
|
||||
'.github',
|
||||
'.tox',
|
||||
'.pytest_cache',
|
||||
'.mypy_cache',
|
||||
'__pypackages__',
|
||||
],
|
||||
// File extensions to remove
|
||||
extensions: [
|
||||
'.pyc',
|
||||
'.pyo',
|
||||
'.pyi', // Type stubs - IDE only, not needed at runtime
|
||||
'.c', // C source files (compiled extensions don't need these)
|
||||
'.h', // C headers
|
||||
'.cpp',
|
||||
'.hpp',
|
||||
'.md',
|
||||
'.rst',
|
||||
'.txt', // Will preserve LICENSE.txt
|
||||
'.yml',
|
||||
'.yaml',
|
||||
'.toml',
|
||||
'.ini',
|
||||
'.cfg',
|
||||
'.coveragerc',
|
||||
'.gitignore',
|
||||
'.gitattributes',
|
||||
'.editorconfig',
|
||||
],
|
||||
// Specific files to remove
|
||||
files: [
|
||||
'README',
|
||||
'README.md',
|
||||
'README.rst',
|
||||
'CHANGELOG',
|
||||
'CHANGELOG.md',
|
||||
'CHANGES',
|
||||
'CHANGES.md',
|
||||
'HISTORY',
|
||||
'HISTORY.md',
|
||||
'AUTHORS',
|
||||
'AUTHORS.md',
|
||||
'CONTRIBUTORS',
|
||||
'CONTRIBUTORS.md',
|
||||
'CONTRIBUTING',
|
||||
'CONTRIBUTING.md',
|
||||
'CODE_OF_CONDUCT.md',
|
||||
'SECURITY.md',
|
||||
'Makefile',
|
||||
'setup.py',
|
||||
'setup.cfg',
|
||||
'pyproject.toml',
|
||||
'tox.ini',
|
||||
'.travis.yml',
|
||||
'conftest.py',
|
||||
'pytest.ini',
|
||||
],
|
||||
// Packages that should NEVER be bundled (too large, specialized)
|
||||
// If these appear in dependencies, warn and skip
|
||||
blockedPackages: [
|
||||
'torch',
|
||||
'torchvision',
|
||||
'torchaudio',
|
||||
'tensorflow',
|
||||
'tensorflow-gpu',
|
||||
'transformers',
|
||||
'jax',
|
||||
'jaxlib',
|
||||
'keras',
|
||||
'onnxruntime',
|
||||
'opencv-python',
|
||||
'opencv-contrib-python',
|
||||
'scipy', // Often pulled in, but large - warn if present
|
||||
],
|
||||
};
|
||||
|
||||
// python-build-standalone release tag
|
||||
const RELEASE_TAG = '20241219';
|
||||
|
||||
@@ -259,10 +350,12 @@ function extractTarGz(archivePath, destDir) {
|
||||
|
||||
// On Windows, use Windows' built-in bsdtar (not Git Bash tar which has path issues)
|
||||
// Git Bash's /usr/bin/tar interprets D: as a remote host, causing extraction to fail
|
||||
// Windows Server 2019+ and Windows 10+ have bsdtar at C:\Windows\System32\tar.exe
|
||||
// Windows Server 2019+ and Windows 10+ have bsdtar at %SystemRoot%\System32\tar.exe
|
||||
if (isWindows) {
|
||||
// Use explicit path to Windows tar to avoid Git Bash's /usr/bin/tar
|
||||
const windowsTar = 'C:\\Windows\\System32\\tar.exe';
|
||||
// Use SystemRoot environment variable to handle non-standard Windows installations
|
||||
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
||||
const windowsTar = path.join(systemRoot, 'System32', 'tar.exe');
|
||||
|
||||
const result = spawnSync(windowsTar, ['-xzf', archivePath, '-C', destDir], {
|
||||
stdio: 'inherit',
|
||||
@@ -313,11 +406,240 @@ function verifyPythonBinary(pythonBin) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to download and set up Python.
|
||||
* Get the size of a directory in bytes.
|
||||
*/
|
||||
async function downloadPython(targetPlatform, targetArch) {
|
||||
function getDirectorySize(dirPath) {
|
||||
let totalSize = 0;
|
||||
|
||||
function walkDir(currentPath) {
|
||||
try {
|
||||
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
try {
|
||||
const stats = fs.statSync(fullPath);
|
||||
totalSize += stats.size;
|
||||
} catch {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(dirPath);
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string.
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unnecessary files from site-packages to reduce bundle size.
|
||||
* This removes tests, docs, cache files, and other non-essential content.
|
||||
*/
|
||||
function stripSitePackages(sitePackagesDir) {
|
||||
console.log(`[download-python] Stripping unnecessary files from site-packages...`);
|
||||
|
||||
const sizeBefore = getDirectorySize(sitePackagesDir);
|
||||
let removedCount = 0;
|
||||
|
||||
function shouldRemoveDir(name) {
|
||||
return STRIP_PATTERNS.dirs.includes(name.toLowerCase());
|
||||
}
|
||||
|
||||
function shouldRemoveFile(name) {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
// Check exact file matches
|
||||
if (STRIP_PATTERNS.files.includes(name) || STRIP_PATTERNS.files.includes(lowerName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check extensions
|
||||
for (const ext of STRIP_PATTERNS.extensions) {
|
||||
if (lowerName.endsWith(ext)) {
|
||||
// Preserve LICENSE files
|
||||
if (lowerName.includes('license')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function walkAndStrip(currentPath) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldRemoveDir(entry.name)) {
|
||||
try {
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
removedCount++;
|
||||
} catch {
|
||||
// Ignore removal errors
|
||||
}
|
||||
} else {
|
||||
walkAndStrip(fullPath);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
if (shouldRemoveFile(entry.name)) {
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
removedCount++;
|
||||
} catch {
|
||||
// Ignore removal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkAndStrip(sitePackagesDir);
|
||||
|
||||
const sizeAfter = getDirectorySize(sitePackagesDir);
|
||||
const savedPercent = ((sizeBefore - sizeAfter) / sizeBefore * 100).toFixed(1);
|
||||
|
||||
console.log(`[download-python] Stripped ${removedCount} files/dirs`);
|
||||
console.log(`[download-python] Size reduced: ${formatBytes(sizeBefore)} → ${formatBytes(sizeAfter)} (saved ${savedPercent}%)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for blocked packages in requirements and warn.
|
||||
*/
|
||||
function checkForBlockedPackages(requirementsPath) {
|
||||
const content = fs.readFileSync(requirementsPath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const blocked = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim().toLowerCase();
|
||||
if (trimmed.startsWith('#') || trimmed === '') continue;
|
||||
|
||||
// Extract package name (before any version specifier)
|
||||
const pkgName = trimmed.split(/[<>=!@[]/)[0].trim();
|
||||
|
||||
for (const blockedPkg of STRIP_PATTERNS.blockedPackages) {
|
||||
if (pkgName === blockedPkg || pkgName.startsWith(`${blockedPkg}-`)) {
|
||||
blocked.push(pkgName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blocked.length > 0) {
|
||||
console.warn(`\n[download-python] ⚠️ WARNING: Large packages detected in requirements:`);
|
||||
for (const pkg of blocked) {
|
||||
console.warn(`[download-python] - ${pkg} (consider making this an on-demand install)`);
|
||||
}
|
||||
console.warn(`[download-python] These packages may significantly increase app size.\n`);
|
||||
}
|
||||
|
||||
return blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Python packages into a site-packages directory.
|
||||
* Uses pip with optimizations for smaller output.
|
||||
*/
|
||||
function installPackages(pythonBin, requirementsPath, targetSitePackages) {
|
||||
console.log(`[download-python] Installing packages from: ${requirementsPath}`);
|
||||
console.log(`[download-python] Target: ${targetSitePackages}`);
|
||||
|
||||
// Check for blocked packages first
|
||||
checkForBlockedPackages(requirementsPath);
|
||||
|
||||
// Ensure target directory exists
|
||||
fs.mkdirSync(targetSitePackages, { recursive: true });
|
||||
|
||||
// Install packages directly to target directory
|
||||
// --no-compile: Don't create .pyc files (saves space, Python will work without them)
|
||||
// --no-cache-dir: Don't use pip cache
|
||||
// --target: Install to specific directory
|
||||
const pipArgs = [
|
||||
'-m', 'pip', 'install',
|
||||
'--no-compile',
|
||||
'--no-cache-dir',
|
||||
'--target', targetSitePackages,
|
||||
'-r', requirementsPath,
|
||||
];
|
||||
|
||||
console.log(`[download-python] Running: ${pythonBin} ${pipArgs.join(' ')}`);
|
||||
|
||||
const result = spawnSync(pythonBin, pipArgs, {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
// Disable bytecode writing
|
||||
PYTHONDONTWRITEBYTECODE: '1',
|
||||
// Use UTF-8 encoding
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run pip: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`pip install failed with exit code ${result.status}`);
|
||||
}
|
||||
|
||||
console.log(`[download-python] Packages installed successfully`);
|
||||
|
||||
// Strip unnecessary files
|
||||
stripSitePackages(targetSitePackages);
|
||||
|
||||
// Remove bin/Scripts directory (we don't need console scripts)
|
||||
const binDir = path.join(targetSitePackages, 'bin');
|
||||
const scriptsDir = path.join(targetSitePackages, 'Scripts');
|
||||
if (fs.existsSync(binDir)) {
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
console.log(`[download-python] Removed bin/ directory`);
|
||||
}
|
||||
if (fs.existsSync(scriptsDir)) {
|
||||
fs.rmSync(scriptsDir, { recursive: true, force: true });
|
||||
console.log(`[download-python] Removed Scripts/ directory`);
|
||||
}
|
||||
|
||||
const finalSize = getDirectorySize(targetSitePackages);
|
||||
console.log(`[download-python] Final site-packages size: ${formatBytes(finalSize)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to download and set up Python.
|
||||
* Downloads Python binary and installs all dependencies into site-packages.
|
||||
*
|
||||
* @param {string} targetPlatform - Target platform (darwin, win32, linux)
|
||||
* @param {string} targetArch - Target architecture (x64, arm64)
|
||||
* @param {Object} options - Additional options
|
||||
* @param {boolean} options.skipPackages - Skip package installation (just download Python)
|
||||
* @param {string} options.requirementsPath - Custom path to requirements.txt
|
||||
*/
|
||||
async function downloadPython(targetPlatform, targetArch, options = {}) {
|
||||
const platform = targetPlatform || os.platform();
|
||||
const arch = targetArch || os.arch();
|
||||
const { skipPackages = false, requirementsPath: customRequirementsPath } = options;
|
||||
|
||||
const info = getDownloadInfo(platform, arch);
|
||||
console.log(`[download-python] Setting up Python ${PYTHON_VERSION} for ${info.outputDir}`);
|
||||
@@ -326,69 +648,120 @@ async function downloadPython(targetPlatform, targetArch) {
|
||||
const runtimeDir = path.join(frontendDir, OUTPUT_DIR);
|
||||
const platformDir = path.join(runtimeDir, info.outputDir);
|
||||
|
||||
// Check if already downloaded
|
||||
// Paths for Python binary and site-packages
|
||||
const pythonBin = info.nodePlatform === 'win32'
|
||||
? path.join(platformDir, 'python', 'python.exe')
|
||||
: path.join(platformDir, 'python', 'bin', 'python3');
|
||||
|
||||
if (fs.existsSync(pythonBin)) {
|
||||
console.log(`[download-python] Python already exists at ${pythonBin}`);
|
||||
const sitePackagesDir = path.join(platformDir, 'site-packages');
|
||||
|
||||
// Verify it works
|
||||
// Path to requirements.txt (in backend directory)
|
||||
const requirementsPath = customRequirementsPath || path.join(frontendDir, '..', 'backend', 'requirements.txt');
|
||||
|
||||
// Check if already fully set up (Python + packages)
|
||||
const packagesMarker = path.join(sitePackagesDir, '.bundled');
|
||||
if (fs.existsSync(pythonBin) && fs.existsSync(packagesMarker)) {
|
||||
console.log(`[download-python] Python and packages already bundled at ${platformDir}`);
|
||||
|
||||
// Verify Python works
|
||||
try {
|
||||
const version = verifyPythonBinary(pythonBin);
|
||||
console.log(`[download-python] Verified: ${version}`);
|
||||
return { success: true, pythonPath: pythonBin };
|
||||
return { success: true, pythonPath: pythonBin, sitePackagesPath: sitePackagesDir };
|
||||
} catch {
|
||||
console.log(`[download-python] Existing Python is broken, re-downloading...`);
|
||||
// Remove broken installation
|
||||
console.log(`[download-python] Existing installation is broken, re-downloading...`);
|
||||
fs.rmSync(platformDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Create directories
|
||||
fs.mkdirSync(platformDir, { recursive: true });
|
||||
// Check if just Python exists (need to install packages)
|
||||
let needsPythonDownload = !fs.existsSync(pythonBin);
|
||||
|
||||
// Download
|
||||
const archivePath = path.join(runtimeDir, info.filename);
|
||||
let needsDownload = true;
|
||||
|
||||
if (fs.existsSync(archivePath)) {
|
||||
console.log(`[download-python] Found cached archive: ${archivePath}`);
|
||||
// Verify cached archive checksum
|
||||
if (fs.existsSync(pythonBin)) {
|
||||
// Verify existing Python
|
||||
try {
|
||||
verifyChecksum(archivePath, info.checksum);
|
||||
needsDownload = false;
|
||||
} catch (err) {
|
||||
console.log(`[download-python] Cached archive failed verification: ${err.message}`);
|
||||
fs.unlinkSync(archivePath);
|
||||
const version = verifyPythonBinary(pythonBin);
|
||||
console.log(`[download-python] Found existing Python: ${version}`);
|
||||
needsPythonDownload = false;
|
||||
} catch {
|
||||
console.log(`[download-python] Existing Python is broken, re-downloading...`);
|
||||
fs.rmSync(platformDir, { recursive: true, force: true });
|
||||
needsPythonDownload = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsDownload) {
|
||||
await downloadFile(info.url, archivePath);
|
||||
// Verify downloaded file
|
||||
verifyChecksum(archivePath, info.checksum);
|
||||
if (needsPythonDownload) {
|
||||
// Create directories
|
||||
fs.mkdirSync(platformDir, { recursive: true });
|
||||
|
||||
// Download
|
||||
const archivePath = path.join(runtimeDir, info.filename);
|
||||
let needsDownload = true;
|
||||
|
||||
if (fs.existsSync(archivePath)) {
|
||||
console.log(`[download-python] Found cached archive: ${archivePath}`);
|
||||
// Verify cached archive checksum
|
||||
try {
|
||||
verifyChecksum(archivePath, info.checksum);
|
||||
needsDownload = false;
|
||||
} catch (err) {
|
||||
console.log(`[download-python] Cached archive failed verification: ${err.message}`);
|
||||
fs.unlinkSync(archivePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (needsDownload) {
|
||||
await downloadFile(info.url, archivePath);
|
||||
// Verify downloaded file
|
||||
verifyChecksum(archivePath, info.checksum);
|
||||
}
|
||||
|
||||
// Extract
|
||||
extractTarGz(archivePath, platformDir);
|
||||
|
||||
// Verify binary exists
|
||||
if (!fs.existsSync(pythonBin)) {
|
||||
throw new Error(`Python binary not found after extraction: ${pythonBin}`);
|
||||
}
|
||||
|
||||
// Make executable on Unix
|
||||
if (info.nodePlatform !== 'win32') {
|
||||
fs.chmodSync(pythonBin, 0o755);
|
||||
}
|
||||
|
||||
// Verify it works
|
||||
const version = verifyPythonBinary(pythonBin);
|
||||
console.log(`[download-python] Installed Python: ${version}`);
|
||||
}
|
||||
|
||||
// Extract
|
||||
extractTarGz(archivePath, platformDir);
|
||||
// Install packages unless skipped
|
||||
if (!skipPackages) {
|
||||
if (!fs.existsSync(requirementsPath)) {
|
||||
console.warn(`[download-python] Warning: requirements.txt not found at ${requirementsPath}`);
|
||||
console.warn(`[download-python] Skipping package installation`);
|
||||
} else {
|
||||
// Remove existing site-packages to ensure clean install
|
||||
if (fs.existsSync(sitePackagesDir)) {
|
||||
console.log(`[download-python] Removing existing site-packages...`);
|
||||
fs.rmSync(sitePackagesDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Verify binary exists
|
||||
if (!fs.existsSync(pythonBin)) {
|
||||
throw new Error(`Python binary not found after extraction: ${pythonBin}`);
|
||||
// Install packages
|
||||
installPackages(pythonBin, requirementsPath, sitePackagesDir);
|
||||
|
||||
// Create marker file to indicate successful bundling
|
||||
fs.writeFileSync(packagesMarker, JSON.stringify({
|
||||
bundledAt: new Date().toISOString(),
|
||||
pythonVersion: PYTHON_VERSION,
|
||||
platform: info.nodePlatform,
|
||||
arch: arch,
|
||||
}, null, 2));
|
||||
|
||||
console.log(`[download-python] Created bundle marker: ${packagesMarker}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Make executable on Unix
|
||||
if (info.nodePlatform !== 'win32') {
|
||||
fs.chmodSync(pythonBin, 0o755);
|
||||
}
|
||||
|
||||
// Verify it works
|
||||
const version = verifyPythonBinary(pythonBin);
|
||||
console.log(`[download-python] Installed: ${version}`);
|
||||
|
||||
return { success: true, pythonPath: pythonBin };
|
||||
return { success: true, pythonPath: pythonBin, sitePackagesPath: sitePackagesDir };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Unit tests for Application Logger Service
|
||||
* Tests logging functionality, debug info collection, and cross-platform compatibility
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Use secure temp directory with random suffix to prevent symlink attacks
|
||||
// These will be initialized in beforeEach with mkdtempSync
|
||||
let TEST_BASE_DIR: string;
|
||||
let TEST_LOGS_DIR: string;
|
||||
let TEST_LOG_FILE: string;
|
||||
|
||||
// Store mock functions for dynamic path updates
|
||||
const mockGetFile = vi.fn();
|
||||
const mockGetPath = vi.fn();
|
||||
|
||||
// Mock electron-log before importing
|
||||
vi.mock('electron-log/main', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
transports: {
|
||||
file: {
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
format: '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}',
|
||||
fileName: 'main.log',
|
||||
level: 'info',
|
||||
getFile: mockGetFile
|
||||
},
|
||||
console: {
|
||||
level: 'warn',
|
||||
format: '[{h}:{i}:{s}] [{level}] {text}'
|
||||
}
|
||||
},
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock electron app
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getVersion: vi.fn(() => '2.7.2-beta.10'),
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
isPackaged: false,
|
||||
getPath: mockGetPath
|
||||
}
|
||||
}));
|
||||
|
||||
// Setup and cleanup helpers
|
||||
function setupTestEnvironment(): void {
|
||||
// Create secure temp directory with random suffix (prevents symlink attacks)
|
||||
TEST_BASE_DIR = mkdtempSync(path.join(tmpdir(), 'app-logger-test-'));
|
||||
TEST_LOGS_DIR = path.join(TEST_BASE_DIR, 'logs');
|
||||
TEST_LOG_FILE = path.join(TEST_LOGS_DIR, 'main.log');
|
||||
|
||||
// Create logs directory
|
||||
mkdirSync(TEST_LOGS_DIR, { recursive: true });
|
||||
|
||||
// Configure mocks to use the secure temp directory
|
||||
mockGetFile.mockReturnValue({ path: TEST_LOG_FILE });
|
||||
mockGetPath.mockImplementation((name: string) => {
|
||||
if (name === 'userData') return TEST_BASE_DIR;
|
||||
if (name === 'logs') return TEST_LOGS_DIR;
|
||||
return TEST_BASE_DIR;
|
||||
});
|
||||
}
|
||||
|
||||
function createTestLogFile(content: string): void {
|
||||
writeFileSync(TEST_LOG_FILE, content);
|
||||
}
|
||||
|
||||
function cleanupTestDirs(): void {
|
||||
if (TEST_BASE_DIR && existsSync(TEST_BASE_DIR)) {
|
||||
rmSync(TEST_BASE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('Application Logger', () => {
|
||||
beforeEach(() => {
|
||||
// Setup fresh secure temp directory for each test
|
||||
setupTestEnvironment();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTestDirs();
|
||||
});
|
||||
|
||||
describe('getSystemInfo', () => {
|
||||
it('should return system information object', async () => {
|
||||
const { getSystemInfo } = await import('../app-logger');
|
||||
|
||||
const info = getSystemInfo();
|
||||
|
||||
expect(info).toHaveProperty('appVersion');
|
||||
expect(info).toHaveProperty('electronVersion');
|
||||
expect(info).toHaveProperty('nodeVersion');
|
||||
expect(info).toHaveProperty('platform');
|
||||
expect(info).toHaveProperty('arch');
|
||||
expect(info).toHaveProperty('osVersion');
|
||||
expect(info).toHaveProperty('osType');
|
||||
expect(info).toHaveProperty('totalMemory');
|
||||
expect(info).toHaveProperty('freeMemory');
|
||||
expect(info).toHaveProperty('cpuCores');
|
||||
expect(info).toHaveProperty('locale');
|
||||
expect(info).toHaveProperty('isPackaged');
|
||||
expect(info).toHaveProperty('userData');
|
||||
});
|
||||
|
||||
it('should return app version from electron', async () => {
|
||||
const { getSystemInfo } = await import('../app-logger');
|
||||
|
||||
const info = getSystemInfo();
|
||||
|
||||
expect(info.appVersion).toBe('2.7.2-beta.10');
|
||||
});
|
||||
|
||||
it('should return valid memory values', async () => {
|
||||
const { getSystemInfo } = await import('../app-logger');
|
||||
|
||||
const info = getSystemInfo();
|
||||
|
||||
expect(info.totalMemory).toMatch(/^\d+GB$/);
|
||||
expect(info.freeMemory).toMatch(/^\d+GB$/);
|
||||
});
|
||||
|
||||
it('should return valid CPU core count', async () => {
|
||||
const { getSystemInfo } = await import('../app-logger');
|
||||
|
||||
const info = getSystemInfo();
|
||||
|
||||
expect(parseInt(info.cpuCores)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogsPath', () => {
|
||||
it('should return logs directory path using path.dirname', async () => {
|
||||
const { getLogsPath } = await import('../app-logger');
|
||||
|
||||
const logsPath = getLogsPath();
|
||||
|
||||
expect(logsPath).toBe(TEST_LOGS_DIR);
|
||||
});
|
||||
|
||||
it('should not include the log file name in the path', async () => {
|
||||
const { getLogsPath } = await import('../app-logger');
|
||||
|
||||
const logsPath = getLogsPath();
|
||||
|
||||
expect(logsPath).not.toContain('main.log');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentLogs', () => {
|
||||
it('should return empty array when log file does not exist', async () => {
|
||||
// Don't create the log file
|
||||
rmSync(TEST_LOG_FILE, { force: true });
|
||||
|
||||
const { getRecentLogs } = await import('../app-logger');
|
||||
const logs = getRecentLogs();
|
||||
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return log lines from file', async () => {
|
||||
const logContent = [
|
||||
'[2024-01-15 10:00:00.000] [info] Application started',
|
||||
'[2024-01-15 10:00:01.000] [info] Loading settings',
|
||||
'[2024-01-15 10:00:02.000] [warn] Settings file not found'
|
||||
].join('\n');
|
||||
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { getRecentLogs } = await import('../app-logger');
|
||||
const logs = getRecentLogs();
|
||||
|
||||
expect(logs).toHaveLength(3);
|
||||
expect(logs[0]).toContain('Application started');
|
||||
});
|
||||
|
||||
it('should respect maxLines parameter', async () => {
|
||||
const logContent = Array.from({ length: 10 }, (_, i) =>
|
||||
`[2024-01-15 10:00:0${i}.000] [info] Log line ${i}`
|
||||
).join('\n');
|
||||
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { getRecentLogs } = await import('../app-logger');
|
||||
const logs = getRecentLogs(5);
|
||||
|
||||
expect(logs).toHaveLength(5);
|
||||
// Should return the last 5 lines
|
||||
expect(logs[0]).toContain('Log line 5');
|
||||
expect(logs[4]).toContain('Log line 9');
|
||||
});
|
||||
|
||||
it('should filter out empty lines', async () => {
|
||||
const logContent = [
|
||||
'[2024-01-15 10:00:00.000] [info] Line 1',
|
||||
'',
|
||||
' ',
|
||||
'[2024-01-15 10:00:01.000] [info] Line 2'
|
||||
].join('\n');
|
||||
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { getRecentLogs } = await import('../app-logger');
|
||||
const logs = getRecentLogs();
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentErrors', () => {
|
||||
it('should filter for error and warn log levels (case insensitive)', async () => {
|
||||
const logContent = [
|
||||
'[2024-01-15 10:00:00.000] [info] Normal log',
|
||||
'[2024-01-15 10:00:01.000] [error] Error occurred',
|
||||
'[2024-01-15 10:00:02.000] [warn] Warning issued',
|
||||
'[2024-01-15 10:00:03.000] [ERROR] Another error',
|
||||
'[2024-01-15 10:00:04.000] [WARN] Another warning',
|
||||
'[2024-01-15 10:00:05.000] [debug] Debug message'
|
||||
].join('\n');
|
||||
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { getRecentErrors } = await import('../app-logger');
|
||||
const errors = getRecentErrors();
|
||||
|
||||
expect(errors).toHaveLength(4);
|
||||
expect(errors.some(e => e.includes('[info]'))).toBe(false);
|
||||
expect(errors.some(e => e.includes('[debug]'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should match JavaScript error types', async () => {
|
||||
const logContent = [
|
||||
'[2024-01-15 10:00:00.000] [info] Normal log',
|
||||
'TypeError: Cannot read property x of undefined',
|
||||
'ReferenceError: foo is not defined',
|
||||
'RangeError: Maximum call stack exceeded',
|
||||
'SyntaxError: Unexpected token',
|
||||
'Error: Something went wrong'
|
||||
].join('\n');
|
||||
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { getRecentErrors } = await import('../app-logger');
|
||||
const errors = getRecentErrors();
|
||||
|
||||
expect(errors).toHaveLength(5);
|
||||
expect(errors.some(e => e.includes('TypeError'))).toBe(true);
|
||||
expect(errors.some(e => e.includes('ReferenceError'))).toBe(true);
|
||||
expect(errors.some(e => e.includes('RangeError'))).toBe(true);
|
||||
expect(errors.some(e => e.includes('SyntaxError'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect maxCount parameter', async () => {
|
||||
const logContent = Array.from({ length: 50 }, (_, i) =>
|
||||
`[2024-01-15 10:00:0${i}.000] [error] Error ${i}`
|
||||
).join('\n');
|
||||
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { getRecentErrors } = await import('../app-logger');
|
||||
const errors = getRecentErrors(10);
|
||||
|
||||
expect(errors).toHaveLength(10);
|
||||
// Should return the last 10 errors
|
||||
expect(errors[0]).toContain('Error 40');
|
||||
expect(errors[9]).toContain('Error 49');
|
||||
});
|
||||
|
||||
it('should return empty array when no errors exist', async () => {
|
||||
const logContent = [
|
||||
'[2024-01-15 10:00:00.000] [info] Normal log 1',
|
||||
'[2024-01-15 10:00:01.000] [info] Normal log 2',
|
||||
'[2024-01-15 10:00:02.000] [debug] Debug message'
|
||||
].join('\n');
|
||||
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { getRecentErrors } = await import('../app-logger');
|
||||
const errors = getRecentErrors();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDebugReport', () => {
|
||||
it('should generate a formatted debug report', async () => {
|
||||
const logContent = [
|
||||
'[2024-01-15 10:00:00.000] [error] Test error'
|
||||
].join('\n');
|
||||
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { generateDebugReport } = await import('../app-logger');
|
||||
const report = generateDebugReport();
|
||||
|
||||
expect(report).toContain('=== Auto Claude Debug Report ===');
|
||||
expect(report).toContain('--- System Information ---');
|
||||
expect(report).toContain('--- Recent Errors ---');
|
||||
expect(report).toContain('=== End Debug Report ===');
|
||||
});
|
||||
|
||||
it('should include system information in report', async () => {
|
||||
createTestLogFile('');
|
||||
|
||||
const { generateDebugReport } = await import('../app-logger');
|
||||
const report = generateDebugReport();
|
||||
|
||||
expect(report).toContain('appVersion:');
|
||||
expect(report).toContain('platform:');
|
||||
expect(report).toContain('electronVersion:');
|
||||
});
|
||||
|
||||
it('should include recent errors in report', async () => {
|
||||
const logContent = '[2024-01-15 10:00:00.000] [error] Critical failure';
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { generateDebugReport } = await import('../app-logger');
|
||||
const report = generateDebugReport();
|
||||
|
||||
expect(report).toContain('Critical failure');
|
||||
});
|
||||
|
||||
it('should show "No recent errors" when no errors exist', async () => {
|
||||
const logContent = '[2024-01-15 10:00:00.000] [info] All good';
|
||||
createTestLogFile(logContent);
|
||||
|
||||
const { generateDebugReport } = await import('../app-logger');
|
||||
const report = generateDebugReport();
|
||||
|
||||
expect(report).toContain('No recent errors');
|
||||
});
|
||||
|
||||
it('should include generation timestamp', async () => {
|
||||
createTestLogFile('');
|
||||
|
||||
const { generateDebugReport } = await import('../app-logger');
|
||||
const report = generateDebugReport();
|
||||
|
||||
expect(report).toContain('Generated:');
|
||||
// Should be ISO format
|
||||
expect(report).toMatch(/Generated: \d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listLogFiles', () => {
|
||||
it('should return empty array when logs directory does not exist', async () => {
|
||||
rmSync(TEST_LOGS_DIR, { recursive: true, force: true });
|
||||
|
||||
const { listLogFiles } = await import('../app-logger');
|
||||
const files = listLogFiles();
|
||||
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('should list log files with metadata', async () => {
|
||||
createTestLogFile('Test log content');
|
||||
writeFileSync(path.join(TEST_LOGS_DIR, 'main.old.log'), 'Old log content');
|
||||
|
||||
const { listLogFiles } = await import('../app-logger');
|
||||
const files = listLogFiles();
|
||||
|
||||
expect(files.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const mainLog = files.find(f => f.name === 'main.log');
|
||||
expect(mainLog).toBeDefined();
|
||||
expect(mainLog?.size).toBeGreaterThan(0);
|
||||
expect(mainLog?.modified).toBeInstanceOf(Date);
|
||||
expect(mainLog?.path).toBe(TEST_LOG_FILE);
|
||||
});
|
||||
|
||||
it('should only include .log files', async () => {
|
||||
createTestLogFile('Log content');
|
||||
writeFileSync(path.join(TEST_LOGS_DIR, 'other.txt'), 'Not a log');
|
||||
writeFileSync(path.join(TEST_LOGS_DIR, 'backup.log.bak'), 'Backup');
|
||||
|
||||
const { listLogFiles } = await import('../app-logger');
|
||||
const files = listLogFiles();
|
||||
|
||||
expect(files.every(f => f.name.endsWith('.log'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should sort files by modification time (newest first)', async () => {
|
||||
// Create files with different modification times
|
||||
createTestLogFile('Current log');
|
||||
|
||||
// Create an older file
|
||||
const oldLogPath = path.join(TEST_LOGS_DIR, 'main.2024-01-01.log');
|
||||
writeFileSync(oldLogPath, 'Old log');
|
||||
|
||||
const { listLogFiles } = await import('../app-logger');
|
||||
const files = listLogFiles();
|
||||
|
||||
if (files.length >= 2) {
|
||||
expect(files[0].modified.getTime()).toBeGreaterThanOrEqual(files[1].modified.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle file stat errors gracefully (TOCTOU)', async () => {
|
||||
createTestLogFile('Test content');
|
||||
|
||||
// The function should handle cases where files are deleted between readdir and stat
|
||||
const { listLogFiles } = await import('../app-logger');
|
||||
const files = listLogFiles();
|
||||
|
||||
// Should not throw, should return available files
|
||||
expect(Array.isArray(files)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupErrorLogging', () => {
|
||||
it('should register process error handlers', async () => {
|
||||
const processSpy = vi.spyOn(process, 'on');
|
||||
|
||||
const { setupErrorLogging } = await import('../app-logger');
|
||||
setupErrorLogging();
|
||||
|
||||
expect(processSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function));
|
||||
expect(processSpy).toHaveBeenCalledWith('unhandledRejection', expect.any(Function));
|
||||
|
||||
processSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Beta version detection', () => {
|
||||
it('should detect beta version from app version', async () => {
|
||||
// The mock returns '2.7.2-beta.10' which should be detected as beta
|
||||
const electronLog = await import('electron-log/main');
|
||||
|
||||
// Beta version should set file level to debug
|
||||
// This is tested implicitly by the mock setup
|
||||
expect(electronLog.default.transports.file.level).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-platform path handling', () => {
|
||||
it('should use path.dirname for safe path extraction', async () => {
|
||||
const { getLogsPath } = await import('../app-logger');
|
||||
const logsPath = getLogsPath();
|
||||
|
||||
// Should be a valid directory path
|
||||
expect(logsPath).not.toContain('main.log');
|
||||
expect(logsPath).toBe(path.dirname(TEST_LOG_FILE));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logger exports', () => {
|
||||
it('should export logger instance', async () => {
|
||||
const { logger } = await import('../app-logger');
|
||||
|
||||
expect(logger).toBeDefined();
|
||||
expect(typeof logger.info).toBe('function');
|
||||
expect(typeof logger.warn).toBe('function');
|
||||
expect(typeof logger.error).toBe('function');
|
||||
expect(typeof logger.debug).toBe('function');
|
||||
});
|
||||
|
||||
it('should export appLog convenience methods', async () => {
|
||||
const { appLog } = await import('../app-logger');
|
||||
|
||||
expect(appLog).toBeDefined();
|
||||
expect(typeof appLog.info).toBe('function');
|
||||
expect(typeof appLog.warn).toBe('function');
|
||||
expect(typeof appLog.error).toBe('function');
|
||||
expect(typeof appLog.debug).toBe('function');
|
||||
expect(typeof appLog.log).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,30 @@ vi.mock('../notification-service', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock electron-log to prevent Electron binary dependency
|
||||
vi.mock('electron-log/main', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
transports: {
|
||||
file: {
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
format: '',
|
||||
fileName: 'main.log',
|
||||
level: 'info',
|
||||
getFile: vi.fn(() => ({ path: '/tmp/test.log' }))
|
||||
},
|
||||
console: {
|
||||
level: 'warn',
|
||||
format: ''
|
||||
}
|
||||
},
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock modules before importing
|
||||
vi.mock('electron', () => {
|
||||
const mockIpcMain = new (class extends EventEmitter {
|
||||
|
||||
@@ -30,6 +30,7 @@ export class AgentManager extends EventEmitter {
|
||||
taskDescription?: string;
|
||||
specDir?: string;
|
||||
metadata?: SpecCreationMetadata;
|
||||
baseBranch?: string;
|
||||
swapCount: number;
|
||||
}> = new Map();
|
||||
|
||||
@@ -91,7 +92,8 @@ export class AgentManager extends EventEmitter {
|
||||
projectPath: string,
|
||||
taskDescription: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata
|
||||
metadata?: SpecCreationMetadata,
|
||||
baseBranch?: string
|
||||
): void {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
const profileManager = getClaudeProfileManager();
|
||||
@@ -125,6 +127,11 @@ export class AgentManager extends EventEmitter {
|
||||
args.push('--spec-dir', specDir);
|
||||
}
|
||||
|
||||
// Pass base branch if specified (ensures worktrees are created from the correct branch)
|
||||
if (baseBranch) {
|
||||
args.push('--base-branch', baseBranch);
|
||||
}
|
||||
|
||||
// Check if user requires review before coding
|
||||
if (!metadata?.requireReviewBeforeCoding) {
|
||||
// Auto-approve: When user starts a task from the UI without requiring review
|
||||
@@ -146,7 +153,7 @@ export class AgentManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata);
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
|
||||
|
||||
// Note: This is spec-creation but it chains to task-execution via run.py
|
||||
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
@@ -332,7 +339,8 @@ export class AgentManager extends EventEmitter {
|
||||
isSpecCreation?: boolean,
|
||||
taskDescription?: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata
|
||||
metadata?: SpecCreationMetadata,
|
||||
baseBranch?: string
|
||||
): void {
|
||||
// Preserve swapCount if context already exists (for restarts)
|
||||
const existingContext = this.taskExecutionContext.get(taskId);
|
||||
@@ -346,6 +354,7 @@ export class AgentManager extends EventEmitter {
|
||||
taskDescription,
|
||||
specDir,
|
||||
metadata,
|
||||
baseBranch,
|
||||
swapCount // Preserve existing count instead of resetting
|
||||
});
|
||||
}
|
||||
@@ -407,7 +416,8 @@ export class AgentManager extends EventEmitter {
|
||||
context.projectPath,
|
||||
context.taskDescription!,
|
||||
context.specDir,
|
||||
context.metadata
|
||||
context.metadata,
|
||||
context.baseBranch
|
||||
);
|
||||
} else {
|
||||
console.log('[AgentManager] Restarting as task execution');
|
||||
|
||||
@@ -10,7 +10,7 @@ import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailu
|
||||
import { projectStore } from '../project-store';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { parsePythonCommand, validatePythonPath } from '../python-detector';
|
||||
import { getConfiguredPythonPath } from '../python-env-manager';
|
||||
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
|
||||
|
||||
/**
|
||||
* Process spawning and lifecycle management
|
||||
@@ -224,11 +224,67 @@ export class AgentProcessManager {
|
||||
const graphitiUrl = project.settings.graphitiMcpUrl || 'http://localhost:8000/mcp/';
|
||||
env['GRAPHITI_MCP_URL'] = graphitiUrl;
|
||||
}
|
||||
|
||||
// CLAUDE.md integration (enabled by default)
|
||||
if (project.settings.useClaudeMd !== false) {
|
||||
env['USE_CLAUDE_MD'] = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables from project's .auto-claude/.env file
|
||||
* This contains frontend-configured settings like memory/Graphiti configuration
|
||||
*/
|
||||
private loadProjectEnv(projectPath: string): Record<string, string> {
|
||||
// Find project by path to get autoBuildPath
|
||||
const projects = projectStore.getProjects();
|
||||
const project = projects.find((p) => p.path === projectPath);
|
||||
|
||||
if (!project?.autoBuildPath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const envPath = path.join(projectPath, project.autoBuildPath, '.env');
|
||||
if (!existsSync(envPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = readFileSync(envPath, 'utf-8');
|
||||
const envVars: Record<string, string> = {};
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
// Skip comments and empty lines
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables from auto-claude .env file
|
||||
*/
|
||||
@@ -289,9 +345,18 @@ export class AgentProcessManager {
|
||||
const spawnId = this.state.generateSpawnId();
|
||||
const env = this.setupProcessEnvironment(extraEnv);
|
||||
|
||||
// Get Python environment (PYTHONPATH for bundled packages, etc.)
|
||||
const pythonEnv = pythonEnvManager.getPythonEnv();
|
||||
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath());
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], { cwd, env });
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...env, // Already includes process.env, extraEnv, profileEnv, PYTHONUNBUFFERED, PYTHONUTF8
|
||||
...pythonEnv // Include Python environment (PYTHONPATH for bundled packages)
|
||||
}
|
||||
});
|
||||
|
||||
this.state.addProcess(taskId, {
|
||||
taskId,
|
||||
@@ -507,10 +572,16 @@ export class AgentProcessManager {
|
||||
|
||||
/**
|
||||
* Get combined environment variables for a project
|
||||
*
|
||||
* Priority (later sources override earlier):
|
||||
* 1. Backend source .env (apps/backend/.env) - CLI defaults
|
||||
* 2. Project's .auto-claude/.env - Frontend-configured settings (memory, integrations)
|
||||
* 3. Project settings (graphitiMcpUrl, useClaudeMd) - Runtime overrides
|
||||
*/
|
||||
getCombinedEnv(projectPath: string): Record<string, string> {
|
||||
const autoBuildEnv = this.loadAutoBuildEnv();
|
||||
const projectEnv = this.getProjectEnvVars(projectPath);
|
||||
return { ...autoBuildEnv, ...projectEnv };
|
||||
const projectFileEnv = this.loadProjectEnv(projectPath);
|
||||
const projectSettingsEnv = this.getProjectEnvVars(projectPath);
|
||||
return { ...autoBuildEnv, ...projectFileEnv, ...projectSettingsEnv };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import { pythonEnvManager } from '../python-env-manager';
|
||||
import { transformIdeaFromSnakeCase, transformSessionFromSnakeCase } from '../ipc-handlers/ideation/transformers';
|
||||
import { transformRoadmapFromSnakeCase } from '../ipc-handlers/roadmap/transformers';
|
||||
import type { RawIdea } from '../ipc-handlers/ideation/types';
|
||||
@@ -216,18 +217,32 @@ export class AgentQueueManager {
|
||||
// Get Python path from process manager (uses venv if configured)
|
||||
const pythonPath = this.processManager.getPythonPath();
|
||||
|
||||
// Get Python environment from pythonEnvManager (includes bundled site-packages)
|
||||
const pythonEnv = pythonEnvManager.getPythonEnv();
|
||||
|
||||
// Build PYTHONPATH: bundled site-packages (if any) + autoBuildSource for local imports
|
||||
const pythonPathParts: string[] = [];
|
||||
if (pythonEnv.PYTHONPATH) {
|
||||
pythonPathParts.push(pythonEnv.PYTHONPATH);
|
||||
}
|
||||
if (autoBuildSource) {
|
||||
pythonPathParts.push(autoBuildSource);
|
||||
}
|
||||
const combinedPythonPath = pythonPathParts.join(process.platform === 'win32' ? ';' : ':');
|
||||
|
||||
// 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
|
||||
// 2. pythonEnv (bundled packages environment)
|
||||
// 3. combinedEnv (auto-claude/.env for CLI usage)
|
||||
// 4. profileEnv (Electron app OAuth token - highest priority)
|
||||
// 5. Our specific overrides
|
||||
const finalEnv = {
|
||||
...process.env,
|
||||
...pythonEnv,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONPATH: autoBuildSource || '', // Allow imports from auto-claude directory
|
||||
PYTHONPATH: combinedPythonPath,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
};
|
||||
|
||||
@@ -391,7 +406,8 @@ export class AgentQueueManager {
|
||||
if (wasIntentionallyStopped) {
|
||||
debugLog('[Agent Queue] Ideation process was intentionally stopped, ignoring exit');
|
||||
this.state.clearKilledSpawn(spawnId);
|
||||
this.state.deleteProcess(projectId);
|
||||
// Note: Don't call deleteProcess here - killProcess() already deleted it.
|
||||
// A new process with the same projectId may have been started.
|
||||
// Emit stopped event to ensure UI updates
|
||||
this.emitter.emit('ideation-stopped', projectId);
|
||||
return;
|
||||
@@ -514,18 +530,32 @@ export class AgentQueueManager {
|
||||
// Get Python path from process manager (uses venv if configured)
|
||||
const pythonPath = this.processManager.getPythonPath();
|
||||
|
||||
// Get Python environment from pythonEnvManager (includes bundled site-packages)
|
||||
const pythonEnv = pythonEnvManager.getPythonEnv();
|
||||
|
||||
// Build PYTHONPATH: bundled site-packages (if any) + autoBuildSource for local imports
|
||||
const pythonPathParts: string[] = [];
|
||||
if (pythonEnv.PYTHONPATH) {
|
||||
pythonPathParts.push(pythonEnv.PYTHONPATH);
|
||||
}
|
||||
if (autoBuildSource) {
|
||||
pythonPathParts.push(autoBuildSource);
|
||||
}
|
||||
const combinedPythonPath = pythonPathParts.join(process.platform === 'win32' ? ';' : ':');
|
||||
|
||||
// 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
|
||||
// 2. pythonEnv (bundled packages environment)
|
||||
// 3. combinedEnv (auto-claude/.env for CLI usage)
|
||||
// 4. profileEnv (Electron app OAuth token - highest priority)
|
||||
// 5. Our specific overrides
|
||||
const finalEnv = {
|
||||
...process.env,
|
||||
...pythonEnv,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONPATH: autoBuildSource || '', // Allow imports from auto-claude directory
|
||||
PYTHONPATH: combinedPythonPath,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
};
|
||||
|
||||
@@ -619,7 +649,8 @@ export class AgentQueueManager {
|
||||
if (wasIntentionallyStopped) {
|
||||
debugLog('[Agent Queue] Roadmap process was intentionally stopped, ignoring exit');
|
||||
this.state.clearKilledSpawn(spawnId);
|
||||
this.state.deleteProcess(projectId);
|
||||
// Note: Don't call deleteProcess here - killProcess() already deleted it.
|
||||
// A new process with the same projectId may have been started.
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Application Logger Service
|
||||
*
|
||||
* Provides persistent, always-on logging for the main process using electron-log.
|
||||
* Logs are stored in the standard OS log directory:
|
||||
* - macOS: ~/Library/Logs/Auto-Claude/
|
||||
* - Windows: %USERPROFILE%\AppData\Roaming\Auto-Claude\logs\
|
||||
* - Linux: ~/.config/Auto-Claude/logs/
|
||||
*
|
||||
* Features:
|
||||
* - Automatic file rotation (7 days, max 10MB per file)
|
||||
* - Always-on logging (not dependent on DEBUG flag)
|
||||
* - Debug info collection for support/bug reports
|
||||
* - Beta version detection for enhanced logging
|
||||
*/
|
||||
|
||||
import log from 'electron-log/main';
|
||||
import { app } from 'electron';
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Configure electron-log (wrapped in try-catch for re-import scenarios in tests)
|
||||
try {
|
||||
log.initialize();
|
||||
} catch {
|
||||
// Already initialized, ignore
|
||||
}
|
||||
|
||||
// File transport configuration
|
||||
log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB max file size
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}';
|
||||
log.transports.file.fileName = 'main.log';
|
||||
|
||||
// Note: We use electron-log's default archiveLogFn which properly rotates logs
|
||||
// by renaming old files to .old format. Custom implementations were problematic.
|
||||
|
||||
// Console transport - always show warnings and errors, debug only in dev mode
|
||||
log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'warn';
|
||||
log.transports.console.format = '[{h}:{i}:{s}] [{level}] {text}';
|
||||
|
||||
// Determine if this is a beta version
|
||||
function isBetaVersion(): boolean {
|
||||
try {
|
||||
const version = app.getVersion();
|
||||
return version.includes('-beta') || version.includes('-alpha') || version.includes('-rc');
|
||||
} catch (error) {
|
||||
log.warn('Failed to detect beta version:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced logging for beta versions
|
||||
if (isBetaVersion()) {
|
||||
log.transports.file.level = 'debug';
|
||||
log.info('Beta version detected - enhanced logging enabled');
|
||||
} else {
|
||||
log.transports.file.level = 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system information for debug reports
|
||||
*/
|
||||
export function getSystemInfo(): Record<string, string> {
|
||||
return {
|
||||
appVersion: app.getVersion(),
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
chromeVersion: process.versions.chrome,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osVersion: os.release(),
|
||||
osType: os.type(),
|
||||
totalMemory: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))}GB`,
|
||||
freeMemory: `${Math.round(os.freemem() / (1024 * 1024 * 1024))}GB`,
|
||||
cpuCores: os.cpus().length.toString(),
|
||||
locale: app.getLocale(),
|
||||
isPackaged: app.isPackaged.toString(),
|
||||
userData: app.getPath('userData'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logs directory path
|
||||
*/
|
||||
export function getLogsPath(): string {
|
||||
try {
|
||||
const filePath = log.transports.file.getFile().path;
|
||||
if (!filePath) {
|
||||
log.warn('Log file path is not available');
|
||||
return '';
|
||||
}
|
||||
return dirname(filePath);
|
||||
} catch (error) {
|
||||
log.error('Failed to get logs path:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent log entries from the current log file
|
||||
*/
|
||||
export function getRecentLogs(maxLines: number = 200): string[] {
|
||||
try {
|
||||
const logPath = log.transports.file.getFile().path;
|
||||
if (!existsSync(logPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = readFileSync(logPath, 'utf-8');
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
return lines.slice(-maxLines);
|
||||
} catch (error) {
|
||||
log.error('Failed to read recent logs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent errors from logs
|
||||
*/
|
||||
export function getRecentErrors(maxCount: number = 20): string[] {
|
||||
const logs = getRecentLogs(1000);
|
||||
// Use case-insensitive matching for log levels and error types
|
||||
const errors = logs.filter(line =>
|
||||
/\[(error|warn)\]/i.test(line) ||
|
||||
/Error:|TypeError:|ReferenceError:|RangeError:|SyntaxError:/i.test(line)
|
||||
);
|
||||
return errors.slice(-maxCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a debug info report for bug reports
|
||||
*/
|
||||
export function generateDebugReport(): string {
|
||||
const systemInfo = getSystemInfo();
|
||||
const recentErrors = getRecentErrors(10);
|
||||
|
||||
const lines = [
|
||||
'=== Auto Claude Debug Report ===',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'--- System Information ---',
|
||||
...Object.entries(systemInfo).map(([key, value]) => `${key}: ${value}`),
|
||||
'',
|
||||
'--- Recent Errors ---',
|
||||
recentErrors.length > 0 ? recentErrors.join('\n') : 'No recent errors',
|
||||
'',
|
||||
'=== End Debug Report ==='
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* List all log files with their metadata
|
||||
*/
|
||||
export function listLogFiles(): Array<{ name: string; path: string; size: number; modified: Date }> {
|
||||
try {
|
||||
const logsDir = getLogsPath();
|
||||
if (!logsDir || !existsSync(logsDir)) {
|
||||
log.debug('Logs directory not available or does not exist');
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = readdirSync(logsDir)
|
||||
.filter(f => f.endsWith('.log'))
|
||||
.map(name => {
|
||||
const filePath = join(logsDir, name);
|
||||
try {
|
||||
// Wrap statSync in try-catch to handle TOCTOU race condition
|
||||
// (file could be deleted between readdirSync and statSync)
|
||||
const stats = statSync(filePath);
|
||||
return {
|
||||
name,
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
modified: stats.mtime
|
||||
};
|
||||
} catch (statError) {
|
||||
log.warn(`Could not stat log file ${filePath}:`, statError);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is { name: string; path: string; size: number; modified: Date } => entry !== null)
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
|
||||
return files;
|
||||
} catch (error) {
|
||||
log.error('Failed to list log files:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the logger for use in other modules
|
||||
export const logger = log;
|
||||
|
||||
// Export convenience methods that match console API
|
||||
export const appLog = {
|
||||
debug: (...args: unknown[]) => log.debug(...args),
|
||||
info: (...args: unknown[]) => log.info(...args),
|
||||
warn: (...args: unknown[]) => log.warn(...args),
|
||||
error: (...args: unknown[]) => log.error(...args),
|
||||
log: (...args: unknown[]) => log.info(...args),
|
||||
};
|
||||
|
||||
// Log unhandled errors
|
||||
export function setupErrorLogging(): void {
|
||||
process.on('uncaughtException', (error) => {
|
||||
log.error('Uncaught exception:', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.error('Unhandled rejection:', reason);
|
||||
});
|
||||
|
||||
log.info('Error logging initialized');
|
||||
}
|
||||
@@ -288,7 +288,7 @@ export class ChangelogGenerator extends EventEmitter {
|
||||
this.debug('Spawn environment', {
|
||||
HOME: spawnEnv.HOME,
|
||||
USER: spawnEnv.USER,
|
||||
pathDirs: spawnEnv.PATH?.split(':').length,
|
||||
pathDirs: spawnEnv.PATH?.split(path.delimiter).length,
|
||||
authMethod: spawnEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (spawnEnv.CLAUDE_CONFIG_DIR ? `config-dir:${spawnEnv.CLAUDE_CONFIG_DIR}` : 'default')
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, BrowserWindow, shell, nativeImage } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { accessSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
|
||||
import { setupIpcHandlers } from './ipc-setup';
|
||||
import { AgentManager } from './agent';
|
||||
@@ -11,8 +11,12 @@ import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handle
|
||||
import { initializeAppUpdater } from './app-updater';
|
||||
import { DEFAULT_APP_SETTINGS } from '../shared/constants';
|
||||
import { readSettingsFile } from './settings-utils';
|
||||
import { setupErrorLogging } from './app-logger';
|
||||
import type { AppSettings } from '../shared/types';
|
||||
|
||||
// Setup error logging early (captures uncaught exceptions)
|
||||
setupErrorLogging();
|
||||
|
||||
/**
|
||||
* Load app settings synchronously (for use during startup).
|
||||
* This is a simple merge with defaults - no migrations or auto-detection.
|
||||
@@ -134,31 +138,79 @@ app.whenReady().then(() => {
|
||||
agentManager = new AgentManager();
|
||||
|
||||
// Load settings and configure agent manager with Python and auto-claude paths
|
||||
// Uses EAFP pattern (try/catch) instead of LBYL (existsSync) to avoid TOCTOU race conditions
|
||||
const settingsPath = join(app.getPath('userData'), 'settings.json');
|
||||
try {
|
||||
const settingsPath = join(app.getPath('userData'), 'settings.json');
|
||||
if (existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
|
||||
// Validate autoBuildPath before using it - must contain runners/spec_runner.py
|
||||
let validAutoBuildPath = settings.autoBuildPath;
|
||||
if (validAutoBuildPath) {
|
||||
const specRunnerPath = join(validAutoBuildPath, 'runners', 'spec_runner.py');
|
||||
if (!existsSync(specRunnerPath)) {
|
||||
// Validate and migrate autoBuildPath - must contain runners/spec_runner.py
|
||||
// Uses EAFP pattern (try/catch with accessSync) instead of existsSync to avoid TOCTOU race conditions
|
||||
let validAutoBuildPath = settings.autoBuildPath;
|
||||
if (validAutoBuildPath) {
|
||||
const specRunnerPath = join(validAutoBuildPath, 'runners', 'spec_runner.py');
|
||||
let specRunnerExists = false;
|
||||
try {
|
||||
accessSync(specRunnerPath);
|
||||
specRunnerExists = true;
|
||||
} catch {
|
||||
// File doesn't exist or isn't accessible
|
||||
}
|
||||
|
||||
if (!specRunnerExists) {
|
||||
// Migration: Try to fix stale paths from old project structure
|
||||
// Old structure: /path/to/project/auto-claude
|
||||
// New structure: /path/to/project/apps/backend
|
||||
let migrated = false;
|
||||
if (validAutoBuildPath.endsWith('/auto-claude') || validAutoBuildPath.endsWith('\\auto-claude')) {
|
||||
const basePath = validAutoBuildPath.replace(/[/\\]auto-claude$/, '');
|
||||
const correctedPath = join(basePath, 'apps', 'backend');
|
||||
const correctedSpecRunnerPath = join(correctedPath, 'runners', 'spec_runner.py');
|
||||
|
||||
let correctedPathExists = false;
|
||||
try {
|
||||
accessSync(correctedSpecRunnerPath);
|
||||
correctedPathExists = true;
|
||||
} catch {
|
||||
// Corrected path doesn't exist
|
||||
}
|
||||
|
||||
if (correctedPathExists) {
|
||||
console.log('[main] Migrating autoBuildPath from old structure:', validAutoBuildPath, '->', correctedPath);
|
||||
settings.autoBuildPath = correctedPath;
|
||||
validAutoBuildPath = correctedPath;
|
||||
migrated = true;
|
||||
|
||||
// Save the corrected setting - we're the only process modifying settings at startup
|
||||
try {
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
console.log('[main] Successfully saved migrated autoBuildPath to settings');
|
||||
} catch (writeError) {
|
||||
console.warn('[main] Failed to save migrated autoBuildPath:', writeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!migrated) {
|
||||
console.warn('[main] Configured autoBuildPath is invalid (missing runners/spec_runner.py), will use auto-detection:', validAutoBuildPath);
|
||||
validAutoBuildPath = undefined; // Let auto-detection find the correct path
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.pythonPath || validAutoBuildPath) {
|
||||
console.warn('[main] Configuring AgentManager with settings:', {
|
||||
pythonPath: settings.pythonPath,
|
||||
autoBuildPath: validAutoBuildPath
|
||||
});
|
||||
agentManager.configure(settings.pythonPath, validAutoBuildPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[main] Failed to load settings for agent configuration:', error);
|
||||
|
||||
if (settings.pythonPath || validAutoBuildPath) {
|
||||
console.warn('[main] Configuring AgentManager with settings:', {
|
||||
pythonPath: settings.pythonPath,
|
||||
autoBuildPath: validAutoBuildPath
|
||||
});
|
||||
agentManager.configure(settings.pythonPath, validAutoBuildPath);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// ENOENT means no settings file yet - that's fine, use defaults
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
// No settings file, use defaults - this is expected on first run
|
||||
} else {
|
||||
console.warn('[main] Failed to load settings for agent configuration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize terminal manager
|
||||
@@ -243,11 +295,5 @@ app.on('before-quit', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught exception:', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('Unhandled rejection:', reason);
|
||||
});
|
||||
// Note: Uncaught exceptions and unhandled rejections are now
|
||||
// logged by setupErrorLogging() in app-logger.ts
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Debug IPC Handlers
|
||||
*
|
||||
* Handles debug-related IPC operations:
|
||||
* - Getting debug info for bug reports
|
||||
* - Opening logs folder
|
||||
* - Copying debug info to clipboard
|
||||
* - Listing log files
|
||||
*/
|
||||
|
||||
import { ipcMain, shell, clipboard } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import {
|
||||
getSystemInfo,
|
||||
getLogsPath,
|
||||
getRecentErrors,
|
||||
generateDebugReport,
|
||||
listLogFiles,
|
||||
logger
|
||||
} from '../app-logger';
|
||||
|
||||
export interface DebugInfo {
|
||||
systemInfo: Record<string, string>;
|
||||
recentErrors: string[];
|
||||
logsPath: string;
|
||||
debugReport: string;
|
||||
}
|
||||
|
||||
export interface LogFileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register debug-related IPC handlers
|
||||
*/
|
||||
export function registerDebugHandlers(): void {
|
||||
// Get comprehensive debug info
|
||||
ipcMain.handle(IPC_CHANNELS.DEBUG_GET_INFO, async (): Promise<DebugInfo> => {
|
||||
logger.info('Debug info requested');
|
||||
return {
|
||||
systemInfo: getSystemInfo(),
|
||||
recentErrors: getRecentErrors(20),
|
||||
logsPath: getLogsPath(),
|
||||
debugReport: generateDebugReport()
|
||||
};
|
||||
});
|
||||
|
||||
// Open logs folder in system file explorer
|
||||
ipcMain.handle(IPC_CHANNELS.DEBUG_OPEN_LOGS_FOLDER, async (): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const logsPath = getLogsPath();
|
||||
logger.info('Opening logs folder:', logsPath);
|
||||
await shell.openPath(logsPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Failed to open logs folder:', error);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
// Copy debug info to clipboard
|
||||
ipcMain.handle(IPC_CHANNELS.DEBUG_COPY_DEBUG_INFO, async (): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const debugReport = generateDebugReport();
|
||||
clipboard.writeText(debugReport);
|
||||
logger.info('Debug info copied to clipboard');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Failed to copy debug info:', error);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent errors
|
||||
ipcMain.handle(IPC_CHANNELS.DEBUG_GET_RECENT_ERRORS, async (_, maxCount?: number): Promise<string[]> => {
|
||||
return getRecentErrors(maxCount ?? 20);
|
||||
});
|
||||
|
||||
// List log files
|
||||
ipcMain.handle(IPC_CHANNELS.DEBUG_LIST_LOG_FILES, async (): Promise<LogFileInfo[]> => {
|
||||
const files = listLogFiles();
|
||||
return files.map(f => ({
|
||||
...f,
|
||||
modified: f.modified.toISOString()
|
||||
}));
|
||||
});
|
||||
|
||||
logger.info('Debug IPC handlers registered');
|
||||
}
|
||||
@@ -183,9 +183,12 @@ export function runPythonSubprocess<T = unknown>(
|
||||
|
||||
/**
|
||||
* Get the Python path for a project's backend
|
||||
* Cross-platform: uses Scripts/python.exe on Windows, bin/python on Unix
|
||||
*/
|
||||
export function getPythonPath(backendPath: string): string {
|
||||
return path.join(backendPath, '.venv', 'bin', 'python');
|
||||
return process.platform === 'win32'
|
||||
? path.join(backendPath, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(backendPath, '.venv', 'bin', 'python');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,13 +316,19 @@ export async function validateGitHubModule(project: Project): Promise<GitHubModu
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Check gh CLI installation
|
||||
// 2. Check gh CLI installation (cross-platform)
|
||||
try {
|
||||
await execAsync('which gh');
|
||||
const whichCommand = process.platform === 'win32' ? 'where gh' : 'which gh';
|
||||
await execAsync(whichCommand);
|
||||
result.ghCliInstalled = true;
|
||||
} catch {
|
||||
result.ghCliInstalled = false;
|
||||
result.error = 'GitHub CLI (gh) is not installed. Install it with:\n macOS: brew install gh\n Linux: See https://cli.github.com/';
|
||||
const installInstructions = process.platform === 'win32'
|
||||
? 'winget install --id GitHub.cli'
|
||||
: process.platform === 'darwin'
|
||||
? 'brew install gh'
|
||||
: 'See https://cli.github.com/';
|
||||
result.error = `GitHub CLI (gh) is not installed. Install it with:\n ${installInstructions}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -340,8 +349,8 @@ export async function validateGitHubModule(project: Project): Promise<GitHubModu
|
||||
result.ghAuthenticated = true;
|
||||
}
|
||||
|
||||
// 4. Check Python virtual environment
|
||||
const venvPath = path.join(backendPath, '.venv', 'bin', 'python');
|
||||
// 4. Check Python virtual environment (cross-platform)
|
||||
const venvPath = getPythonPath(backendPath);
|
||||
result.pythonEnvValid = fs.existsSync(venvPath);
|
||||
|
||||
if (!result.pythonEnvValid) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { registerChangelogHandlers } from './changelog-handlers';
|
||||
import { registerInsightsHandlers } from './insights-handlers';
|
||||
import { registerMemoryHandlers } from './memory-handlers';
|
||||
import { registerAppUpdateHandlers } from './app-update-handlers';
|
||||
import { registerDebugHandlers } from './debug-handlers';
|
||||
import { notificationService } from '../notification-service';
|
||||
|
||||
/**
|
||||
@@ -98,6 +99,9 @@ export function setupIpcHandlers(
|
||||
// App auto-update handlers
|
||||
registerAppUpdateHandlers();
|
||||
|
||||
// Debug handlers (logs, debug info, etc.)
|
||||
registerDebugHandlers();
|
||||
|
||||
console.warn('[IPC] All handler modules registered successfully');
|
||||
}
|
||||
|
||||
@@ -119,5 +123,6 @@ export {
|
||||
registerChangelogHandlers,
|
||||
registerInsightsHandlers,
|
||||
registerMemoryHandlers,
|
||||
registerAppUpdateHandlers
|
||||
registerAppUpdateHandlers,
|
||||
registerDebugHandlers
|
||||
};
|
||||
|
||||
@@ -132,8 +132,10 @@ const initializePythonEnvironment = async (
|
||||
return {
|
||||
ready: false,
|
||||
pythonPath: null,
|
||||
sitePackagesPath: null,
|
||||
venvExists: false,
|
||||
depsInstalled: false,
|
||||
usingBundledPackages: false,
|
||||
error: 'Auto-build source not found'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,17 +117,18 @@ export function registerTaskExecutionHandlers(
|
||||
|
||||
console.warn('[TASK_START] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation);
|
||||
|
||||
// Get base branch from project settings for worktree creation
|
||||
const baseBranch = project.settings?.mainBranch;
|
||||
// Get base branch: task-level override takes precedence over project settings
|
||||
const baseBranch = task.metadata?.baseBranch || project.settings?.mainBranch;
|
||||
|
||||
if (needsSpecCreation) {
|
||||
// No spec file - need to run spec_runner.py to create the spec
|
||||
const taskDescription = task.description || task.title;
|
||||
console.warn('[TASK_START] Starting spec creation for:', task.specId, 'in:', specDir);
|
||||
console.warn('[TASK_START] Starting spec creation for:', task.specId, 'in:', specDir, 'baseBranch:', baseBranch);
|
||||
|
||||
// Start spec creation process - pass the existing spec directory
|
||||
// so spec_runner uses it instead of creating a new one
|
||||
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata);
|
||||
// Also pass baseBranch so worktrees are created from the correct branch
|
||||
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata, baseBranch);
|
||||
} else if (needsImplementation) {
|
||||
// Spec exists but no subtasks - run run.py to create implementation plan and execute
|
||||
// Read the spec.md to get the task description
|
||||
@@ -483,11 +484,14 @@ export function registerTaskExecutionHandlers(
|
||||
|
||||
console.warn('[TASK_UPDATE_STATUS] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation);
|
||||
|
||||
// Get base branch: task-level override takes precedence over project settings
|
||||
const baseBranchForUpdate = task.metadata?.baseBranch || project.settings?.mainBranch;
|
||||
|
||||
if (needsSpecCreation) {
|
||||
// No spec file - need to run spec_runner.py to create the spec
|
||||
const taskDescription = task.description || task.title;
|
||||
console.warn('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId);
|
||||
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata);
|
||||
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate);
|
||||
} else if (needsImplementation) {
|
||||
// Spec exists but no subtasks - run run.py to create implementation plan and execute
|
||||
console.warn('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId);
|
||||
@@ -497,7 +501,8 @@ export function registerTaskExecutionHandlers(
|
||||
task.specId,
|
||||
{
|
||||
parallel: false,
|
||||
workers: 1
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForUpdate
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -510,7 +515,8 @@ export function registerTaskExecutionHandlers(
|
||||
task.specId,
|
||||
{
|
||||
parallel: false,
|
||||
workers: 1
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForUpdate
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -755,11 +761,14 @@ export function registerTaskExecutionHandlers(
|
||||
const hasSpec = existsSync(specFilePath);
|
||||
const needsSpecCreation = !hasSpec;
|
||||
|
||||
// Get base branch: task-level override takes precedence over project settings
|
||||
const baseBranchForRecovery = task.metadata?.baseBranch || project.settings?.mainBranch;
|
||||
|
||||
if (needsSpecCreation) {
|
||||
// No spec file - need to run spec_runner.py to create the spec
|
||||
const taskDescription = task.description || task.title;
|
||||
console.warn(`[Recovery] Starting spec creation for: ${task.specId}`);
|
||||
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDirForWatcher, task.metadata);
|
||||
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery);
|
||||
} else {
|
||||
// Spec exists - run task execution
|
||||
console.warn(`[Recovery] Starting task execution for: ${task.specId}`);
|
||||
@@ -769,7 +778,8 @@ export function registerTaskExecutionHandlers(
|
||||
task.specId,
|
||||
{
|
||||
parallel: false,
|
||||
workers: 1
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForRecovery
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
import { app } from 'electron';
|
||||
@@ -8,24 +8,38 @@ import { findPythonCommand, getBundledPythonPath } from './python-detector';
|
||||
export interface PythonEnvStatus {
|
||||
ready: boolean;
|
||||
pythonPath: string | null;
|
||||
sitePackagesPath: string | null;
|
||||
venvExists: boolean;
|
||||
depsInstalled: boolean;
|
||||
usingBundledPackages: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the Python virtual environment for the auto-claude backend.
|
||||
* Automatically creates venv and installs dependencies if needed.
|
||||
* Manages the Python environment for the auto-claude backend.
|
||||
*
|
||||
* For packaged apps:
|
||||
* - Uses bundled Python binary (resources/python/)
|
||||
* - Uses bundled site-packages (resources/python-site-packages/)
|
||||
* - No venv creation or pip install needed - everything is pre-bundled
|
||||
*
|
||||
* For development mode:
|
||||
* - Creates venv in the source directory
|
||||
* - Installs dependencies via pip
|
||||
*
|
||||
* On packaged apps (especially Linux AppImages), the bundled source is read-only,
|
||||
* so we create the venv in userData instead of inside the source directory.
|
||||
* so for dev mode fallback we create the venv in userData instead.
|
||||
*/
|
||||
export class PythonEnvManager extends EventEmitter {
|
||||
private autoBuildSourcePath: string | null = null;
|
||||
private pythonPath: string | null = null;
|
||||
private sitePackagesPath: string | null = null;
|
||||
private usingBundledPackages = false;
|
||||
private isInitializing = false;
|
||||
private isReady = false;
|
||||
private initializationPromise: Promise<PythonEnvStatus> | null = null;
|
||||
private activeProcesses: Set<ChildProcess> = new Set();
|
||||
private static readonly VENV_CREATION_TIMEOUT_MS = 120000; // 2 minutes timeout for venv creation
|
||||
|
||||
/**
|
||||
* Get the path where the venv should be created.
|
||||
@@ -78,17 +92,87 @@ export class PythonEnvManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if claude-agent-sdk is installed
|
||||
* Get the path to bundled site-packages (for packaged apps).
|
||||
* These are pre-installed during the build process.
|
||||
*/
|
||||
private getBundledSitePackagesPath(): string | null {
|
||||
if (!app.isPackaged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sitePackagesPath = path.join(process.resourcesPath, 'python-site-packages');
|
||||
|
||||
if (existsSync(sitePackagesPath)) {
|
||||
console.log(`[PythonEnvManager] Found bundled site-packages at: ${sitePackagesPath}`);
|
||||
return sitePackagesPath;
|
||||
}
|
||||
|
||||
console.log(`[PythonEnvManager] Bundled site-packages not found at: ${sitePackagesPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bundled packages are available and valid.
|
||||
* For packaged apps, we check if the bundled site-packages directory exists
|
||||
* and contains the marker file indicating successful bundling.
|
||||
*/
|
||||
private hasBundledPackages(): boolean {
|
||||
const sitePackagesPath = this.getBundledSitePackagesPath();
|
||||
if (!sitePackagesPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for the marker file that indicates successful bundling
|
||||
const markerPath = path.join(sitePackagesPath, '.bundled');
|
||||
if (existsSync(markerPath)) {
|
||||
console.log(`[PythonEnvManager] Found bundle marker, using bundled packages`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: check if key packages exist
|
||||
// This handles cases where the marker might be missing but packages are there
|
||||
const claudeSdkPath = path.join(sitePackagesPath, 'claude_agent_sdk');
|
||||
const dotenvPath = path.join(sitePackagesPath, 'dotenv');
|
||||
if (existsSync(claudeSdkPath) || existsSync(dotenvPath)) {
|
||||
console.log(`[PythonEnvManager] Found key packages, using bundled packages`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if required dependencies are installed.
|
||||
* Verifies all packages that must be present for the backend to work.
|
||||
* This ensures users don't encounter broken functionality when using features.
|
||||
*/
|
||||
private async checkDepsInstalled(): Promise<boolean> {
|
||||
const venvPython = this.getVenvPythonPath();
|
||||
if (!venvPython || !existsSync(venvPython)) return false;
|
||||
|
||||
try {
|
||||
// Check if claude_agent_sdk can be imported
|
||||
execSync(`"${venvPython}" -c "import claude_agent_sdk"`, {
|
||||
// Check all dependencies - if any fail, we need to reinstall
|
||||
// This prevents issues where partial installs leave some packages missing
|
||||
// See: https://github.com/AndyMik90/Auto-Claude/issues/359
|
||||
//
|
||||
// Dependencies checked:
|
||||
// - claude_agent_sdk: Core agent SDK (required)
|
||||
// - dotenv: Environment variable loading (required)
|
||||
// - google.generativeai: Google AI/Gemini support (required for full functionality)
|
||||
// - real_ladybug + graphiti_core: Graphiti memory system (Python 3.12+ only)
|
||||
const checkScript = `
|
||||
import sys
|
||||
import claude_agent_sdk
|
||||
import dotenv
|
||||
import google.generativeai
|
||||
# Graphiti dependencies only available on Python 3.12+
|
||||
if sys.version_info >= (3, 12):
|
||||
import real_ladybug
|
||||
import graphiti_core
|
||||
`;
|
||||
execSync(`"${venvPython}" -c "${checkScript.replace(/\n/g, '; ').replace(/; ; /g, '; ')}"`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000
|
||||
timeout: 15000
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
@@ -160,12 +244,38 @@ export class PythonEnvManager extends EventEmitter {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
// Track the process for cleanup on app exit
|
||||
this.activeProcesses.add(proc);
|
||||
|
||||
let stderr = '';
|
||||
let resolved = false;
|
||||
|
||||
// Set up timeout to kill hung venv creation
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
console.error('[PythonEnvManager] Venv creation timed out after', PythonEnvManager.VENV_CREATION_TIMEOUT_MS, 'ms');
|
||||
this.emit('error', 'Virtual environment creation timed out. This may indicate a system issue.');
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
this.activeProcesses.delete(proc);
|
||||
resolve(false);
|
||||
}
|
||||
}, PythonEnvManager.VENV_CREATION_TIMEOUT_MS);
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (resolved) return; // Already handled by timeout
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
this.activeProcesses.delete(proc);
|
||||
|
||||
if (code === 0) {
|
||||
console.warn('[PythonEnvManager] Venv created successfully');
|
||||
resolve(true);
|
||||
@@ -177,6 +287,11 @@ export class PythonEnvManager extends EventEmitter {
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
if (resolved) return; // Already handled by timeout
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
this.activeProcesses.delete(proc);
|
||||
|
||||
console.error('[PythonEnvManager] Error creating venv:', err);
|
||||
this.emit('error', `Failed to create virtual environment: ${err.message}`);
|
||||
resolve(false);
|
||||
@@ -294,7 +409,9 @@ export class PythonEnvManager extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Initialize the Python environment.
|
||||
* Creates venv and installs deps if needed.
|
||||
*
|
||||
* For packaged apps: Uses bundled Python + site-packages (no pip install needed)
|
||||
* For development: Creates venv and installs deps if needed.
|
||||
*
|
||||
* If initialization is already in progress, this will wait for and return
|
||||
* the existing initialization promise instead of starting a new one.
|
||||
@@ -311,8 +428,10 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return {
|
||||
ready: true,
|
||||
pythonPath: this.pythonPath,
|
||||
sitePackagesPath: this.sitePackagesPath,
|
||||
venvExists: true,
|
||||
depsInstalled: true
|
||||
depsInstalled: true,
|
||||
usingBundledPackages: this.usingBundledPackages
|
||||
};
|
||||
}
|
||||
|
||||
@@ -337,6 +456,39 @@ export class PythonEnvManager extends EventEmitter {
|
||||
console.warn('[PythonEnvManager] Initializing with path:', autoBuildSourcePath);
|
||||
|
||||
try {
|
||||
// For packaged apps, try to use bundled packages first (no pip install needed!)
|
||||
if (app.isPackaged && this.hasBundledPackages()) {
|
||||
console.warn('[PythonEnvManager] Using bundled Python packages (no pip install needed)');
|
||||
|
||||
const bundledPython = getBundledPythonPath();
|
||||
const bundledSitePackages = this.getBundledSitePackagesPath();
|
||||
|
||||
if (bundledPython && bundledSitePackages) {
|
||||
this.pythonPath = bundledPython;
|
||||
this.sitePackagesPath = bundledSitePackages;
|
||||
this.usingBundledPackages = true;
|
||||
this.isReady = true;
|
||||
this.isInitializing = false;
|
||||
|
||||
this.emit('ready', this.pythonPath);
|
||||
console.warn('[PythonEnvManager] Ready with bundled Python:', this.pythonPath);
|
||||
console.warn('[PythonEnvManager] Using bundled site-packages:', this.sitePackagesPath);
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
pythonPath: this.pythonPath,
|
||||
sitePackagesPath: this.sitePackagesPath,
|
||||
venvExists: false, // Not using venv
|
||||
depsInstalled: true,
|
||||
usingBundledPackages: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to venv-based setup (for development or if bundled packages missing)
|
||||
console.warn('[PythonEnvManager] Using venv-based setup (development mode or bundled packages missing)');
|
||||
this.usingBundledPackages = false;
|
||||
|
||||
// Check if venv exists
|
||||
if (!this.venvExists()) {
|
||||
console.warn('[PythonEnvManager] Venv not found, creating...');
|
||||
@@ -346,8 +498,10 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return {
|
||||
ready: false,
|
||||
pythonPath: null,
|
||||
sitePackagesPath: null,
|
||||
venvExists: false,
|
||||
depsInstalled: false,
|
||||
usingBundledPackages: false,
|
||||
error: 'Failed to create virtual environment'
|
||||
};
|
||||
}
|
||||
@@ -365,8 +519,10 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return {
|
||||
ready: false,
|
||||
pythonPath: this.getVenvPythonPath(),
|
||||
sitePackagesPath: null,
|
||||
venvExists: true,
|
||||
depsInstalled: false,
|
||||
usingBundledPackages: false,
|
||||
error: 'Failed to install dependencies'
|
||||
};
|
||||
}
|
||||
@@ -375,6 +531,34 @@ export class PythonEnvManager extends EventEmitter {
|
||||
}
|
||||
|
||||
this.pythonPath = this.getVenvPythonPath();
|
||||
// For venv, site-packages is inside the venv
|
||||
const venvBase = this.getVenvBasePath();
|
||||
if (venvBase) {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows venv structure: Lib/site-packages (no python version subfolder)
|
||||
this.sitePackagesPath = path.join(venvBase, 'Lib', 'site-packages');
|
||||
} else {
|
||||
// Unix venv structure: lib/python3.x/site-packages
|
||||
// Dynamically detect Python version from venv lib directory
|
||||
const libDir = path.join(venvBase, 'lib');
|
||||
let pythonVersion = 'python3.12'; // Fallback to bundled version
|
||||
|
||||
if (existsSync(libDir)) {
|
||||
try {
|
||||
const entries = readdirSync(libDir);
|
||||
const pythonDir = entries.find(e => e.startsWith('python3.'));
|
||||
if (pythonDir) {
|
||||
pythonVersion = pythonDir;
|
||||
}
|
||||
} catch {
|
||||
// Use fallback version
|
||||
}
|
||||
}
|
||||
|
||||
this.sitePackagesPath = path.join(venvBase, 'lib', pythonVersion, 'site-packages');
|
||||
}
|
||||
}
|
||||
|
||||
this.isReady = true;
|
||||
this.isInitializing = false;
|
||||
|
||||
@@ -384,8 +568,10 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return {
|
||||
ready: true,
|
||||
pythonPath: this.pythonPath,
|
||||
sitePackagesPath: this.sitePackagesPath,
|
||||
venvExists: true,
|
||||
depsInstalled: true
|
||||
depsInstalled: true,
|
||||
usingBundledPackages: false
|
||||
};
|
||||
} catch (error) {
|
||||
this.isInitializing = false;
|
||||
@@ -393,8 +579,10 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return {
|
||||
ready: false,
|
||||
pythonPath: null,
|
||||
sitePackagesPath: null,
|
||||
venvExists: this.venvExists(),
|
||||
depsInstalled: false,
|
||||
usingBundledPackages: false,
|
||||
error: message
|
||||
};
|
||||
}
|
||||
@@ -407,6 +595,20 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return this.pythonPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site-packages path (only valid after initialization)
|
||||
*/
|
||||
getSitePackagesPath(): string | null {
|
||||
return this.sitePackagesPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using bundled packages (vs venv)
|
||||
*/
|
||||
isUsingBundledPackages(): boolean {
|
||||
return this.usingBundledPackages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the environment is ready
|
||||
*/
|
||||
@@ -414,25 +616,86 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables that should be set when spawning Python processes.
|
||||
* This ensures Python finds the bundled packages or venv packages.
|
||||
*/
|
||||
getPythonEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {
|
||||
// Don't write bytecode - not needed and avoids permission issues
|
||||
PYTHONDONTWRITEBYTECODE: '1',
|
||||
// Use UTF-8 encoding
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
// Disable user site-packages to avoid conflicts
|
||||
PYTHONNOUSERSITE: '1',
|
||||
};
|
||||
|
||||
// Set PYTHONPATH to our site-packages
|
||||
if (this.sitePackagesPath) {
|
||||
env.PYTHONPATH = this.sitePackagesPath;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status
|
||||
*/
|
||||
async getStatus(): Promise<PythonEnvStatus> {
|
||||
// If using bundled packages, we're always ready
|
||||
if (this.usingBundledPackages && this.pythonPath && this.sitePackagesPath) {
|
||||
return {
|
||||
ready: true,
|
||||
pythonPath: this.pythonPath,
|
||||
sitePackagesPath: this.sitePackagesPath,
|
||||
venvExists: false,
|
||||
depsInstalled: true,
|
||||
usingBundledPackages: true
|
||||
};
|
||||
}
|
||||
|
||||
const venvExists = this.venvExists();
|
||||
const depsInstalled = venvExists ? await this.checkDepsInstalled() : false;
|
||||
|
||||
return {
|
||||
ready: this.isReady,
|
||||
pythonPath: this.pythonPath,
|
||||
sitePackagesPath: this.sitePackagesPath,
|
||||
venvExists,
|
||||
depsInstalled
|
||||
depsInstalled,
|
||||
usingBundledPackages: this.usingBundledPackages
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up any active processes on app exit.
|
||||
* Should be called when the application is about to quit.
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (this.activeProcesses.size > 0) {
|
||||
console.warn('[PythonEnvManager] Cleaning up', this.activeProcesses.size, 'active process(es)');
|
||||
for (const proc of this.activeProcesses) {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
}
|
||||
this.activeProcesses.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const pythonEnvManager = new PythonEnvManager();
|
||||
|
||||
// Register cleanup on app exit (guard for test environments where app.on may not exist)
|
||||
if (typeof app?.on === 'function') {
|
||||
app.on('will-quit', () => {
|
||||
pythonEnvManager.cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured venv Python path if ready, otherwise fall back to system Python.
|
||||
* This should be used by ALL services that need to spawn Python processes.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IdeationAPI, createIdeationAPI } from './modules/ideation-api';
|
||||
import { InsightsAPI, createInsightsAPI } from './modules/insights-api';
|
||||
import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api';
|
||||
import { GitHubAPI, createGitHubAPI } from './modules/github-api';
|
||||
import { DebugAPI, createDebugAPI } from './modules/debug-api';
|
||||
|
||||
export interface ElectronAPI extends
|
||||
ProjectAPI,
|
||||
@@ -18,7 +19,8 @@ export interface ElectronAPI extends
|
||||
AgentAPI,
|
||||
IdeationAPI,
|
||||
InsightsAPI,
|
||||
AppUpdateAPI {
|
||||
AppUpdateAPI,
|
||||
DebugAPI {
|
||||
github: GitHubAPI;
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createIdeationAPI(),
|
||||
...createInsightsAPI(),
|
||||
...createAppUpdateAPI(),
|
||||
...createDebugAPI(),
|
||||
github: createGitHubAPI()
|
||||
});
|
||||
|
||||
@@ -46,7 +49,8 @@ export {
|
||||
createIdeationAPI,
|
||||
createInsightsAPI,
|
||||
createAppUpdateAPI,
|
||||
createGitHubAPI
|
||||
createGitHubAPI,
|
||||
createDebugAPI
|
||||
};
|
||||
|
||||
export type {
|
||||
@@ -59,5 +63,6 @@ export type {
|
||||
IdeationAPI,
|
||||
InsightsAPI,
|
||||
AppUpdateAPI,
|
||||
GitHubAPI
|
||||
GitHubAPI,
|
||||
DebugAPI
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Debug API for renderer process
|
||||
*
|
||||
* Provides access to debugging features:
|
||||
* - Get debug info for bug reports
|
||||
* - Open logs folder
|
||||
* - Copy debug info to clipboard
|
||||
* - List log files
|
||||
*/
|
||||
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import { invokeIpc } from './ipc-utils';
|
||||
|
||||
export interface DebugInfo {
|
||||
systemInfo: Record<string, string>;
|
||||
recentErrors: string[];
|
||||
logsPath: string;
|
||||
debugReport: string;
|
||||
}
|
||||
|
||||
export interface LogFileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface DebugResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug API interface exposed to renderer
|
||||
*/
|
||||
export interface DebugAPI {
|
||||
getDebugInfo: () => Promise<DebugInfo>;
|
||||
openLogsFolder: () => Promise<DebugResult>;
|
||||
copyDebugInfo: () => Promise<DebugResult>;
|
||||
getRecentErrors: (maxCount?: number) => Promise<string[]>;
|
||||
listLogFiles: () => Promise<LogFileInfo[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Debug API implementation
|
||||
*/
|
||||
export const createDebugAPI = (): DebugAPI => ({
|
||||
getDebugInfo: (): Promise<DebugInfo> =>
|
||||
invokeIpc(IPC_CHANNELS.DEBUG_GET_INFO),
|
||||
|
||||
openLogsFolder: (): Promise<DebugResult> =>
|
||||
invokeIpc(IPC_CHANNELS.DEBUG_OPEN_LOGS_FOLDER),
|
||||
|
||||
copyDebugInfo: (): Promise<DebugResult> =>
|
||||
invokeIpc(IPC_CHANNELS.DEBUG_COPY_DEBUG_INFO),
|
||||
|
||||
getRecentErrors: (maxCount?: number): Promise<string[]> =>
|
||||
invokeIpc(IPC_CHANNELS.DEBUG_GET_RECENT_ERRORS, maxCount),
|
||||
|
||||
listLogFiles: (): Promise<LogFileInfo[]> =>
|
||||
invokeIpc(IPC_CHANNELS.DEBUG_LIST_LOG_FILES)
|
||||
});
|
||||
@@ -13,3 +13,4 @@ export * from './linear-api';
|
||||
export * from './github-api';
|
||||
export * from './autobuild-api';
|
||||
export * from './shell-api';
|
||||
export * from './debug-api';
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
ImplementationPlan,
|
||||
TaskMetadata,
|
||||
TaskLogs,
|
||||
TaskLogStreamChunk
|
||||
TaskLogStreamChunk,
|
||||
SupportedIDE,
|
||||
SupportedTerminal
|
||||
} from '../../shared/types';
|
||||
|
||||
export interface TaskAPI {
|
||||
@@ -50,6 +52,9 @@ export interface TaskAPI {
|
||||
mergeWorktreePreview: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeMergeResult>>;
|
||||
discardWorktree: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeDiscardResult>>;
|
||||
listWorktrees: (projectId: string) => Promise<IPCResult<import('../../shared/types').WorktreeListResult>>;
|
||||
worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
|
||||
worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
|
||||
worktreeDetectTools: () => Promise<IPCResult<{ ides: Array<{ id: string; name: string; path: string; installed: boolean }>; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>>;
|
||||
archiveTasks: (projectId: string, taskIds: string[], version?: string) => Promise<IPCResult<boolean>>;
|
||||
unarchiveTasks: (projectId: string, taskIds: string[]) => Promise<IPCResult<boolean>>;
|
||||
|
||||
@@ -139,6 +144,15 @@ export const createTaskAPI = (): TaskAPI => ({
|
||||
listWorktrees: (projectId: string): Promise<IPCResult<import('../../shared/types').WorktreeListResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId),
|
||||
|
||||
worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string): Promise<IPCResult<{ opened: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_OPEN_IN_IDE, worktreePath, ide, customPath),
|
||||
|
||||
worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string): Promise<IPCResult<{ opened: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_OPEN_IN_TERMINAL, worktreePath, terminal, customPath),
|
||||
|
||||
worktreeDetectTools: (): Promise<IPCResult<{ ides: Array<{ id: string; name: string; path: string; installed: boolean }>; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DETECT_TOOLS),
|
||||
|
||||
archiveTasks: (projectId: string, taskIds: string[], version?: string): Promise<IPCResult<boolean>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_ARCHIVE, projectId, taskIds, version),
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import { GitHubIssues } from './components/GitHubIssues';
|
||||
import { GitHubPRs } from './components/github-prs';
|
||||
import { Changelog } from './components/Changelog';
|
||||
import { Worktrees } from './components/Worktrees';
|
||||
import { AgentTools } from './components/AgentTools';
|
||||
import { WelcomeScreen } from './components/WelcomeScreen';
|
||||
import { RateLimitModal } from './components/RateLimitModal';
|
||||
import { SDKRateLimitModal } from './components/SDKRateLimitModal';
|
||||
@@ -57,10 +58,13 @@ import { useTaskStore, loadTasks } from './stores/task-store';
|
||||
import { useSettingsStore, loadSettings } from './stores/settings-store';
|
||||
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
|
||||
import { initializeGitHubListeners } from './stores/github';
|
||||
import { initDownloadProgressListener } from './stores/download-store';
|
||||
import { GlobalDownloadIndicator } from './components/GlobalDownloadIndicator';
|
||||
import { useIpcListeners } from './hooks/useIpc';
|
||||
import { COLOR_THEMES, UI_SCALE_MIN, UI_SCALE_MAX, UI_SCALE_DEFAULT } from '../shared/constants';
|
||||
import type { Task, Project, ColorTheme } from '../shared/types';
|
||||
import { ProjectTabBar } from './components/ProjectTabBar';
|
||||
import { AddProjectModal } from './components/AddProjectModal';
|
||||
|
||||
export function App() {
|
||||
// Load IPC listeners for real-time updates
|
||||
@@ -96,6 +100,7 @@ export function App() {
|
||||
const [initSuccess, setInitSuccess] = useState(false);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const [skippedInitProjectId, setSkippedInitProjectId] = useState<string | null>(null);
|
||||
const [showAddProjectModal, setShowAddProjectModal] = useState(false);
|
||||
|
||||
// GitHub setup state (shown after Auto Claude init)
|
||||
const [showGitHubSetup, setShowGitHubSetup] = useState(false);
|
||||
@@ -123,6 +128,12 @@ export function App() {
|
||||
loadSettings();
|
||||
// Initialize global GitHub listeners (PR reviews, etc.) so they persist across navigation
|
||||
initializeGitHubListeners();
|
||||
// Initialize global download progress listener for Ollama model downloads
|
||||
const cleanupDownloadListener = initDownloadProgressListener();
|
||||
|
||||
return () => {
|
||||
cleanupDownloadListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Restore tab state and open tabs for loaded projects
|
||||
@@ -416,26 +427,17 @@ export function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProject = async () => {
|
||||
try {
|
||||
const path = await window.electronAPI.selectDirectory();
|
||||
if (path) {
|
||||
const project = await addProject(path);
|
||||
if (project) {
|
||||
// Open a tab for the new project
|
||||
openProjectTab(project.id);
|
||||
const handleAddProject = () => {
|
||||
setShowAddProjectModal(true);
|
||||
};
|
||||
|
||||
if (!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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add project:', error);
|
||||
const handleProjectAdded = (project: Project, needsInit: boolean) => {
|
||||
openProjectTab(project.id);
|
||||
if (needsInit) {
|
||||
setPendingProject(project);
|
||||
setInitError(null);
|
||||
setInitSuccess(false);
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -712,16 +714,7 @@ export function App() {
|
||||
{activeView === 'worktrees' && (activeProjectId || selectedProjectId) && (
|
||||
<Worktrees projectId={activeProjectId || selectedProjectId!} />
|
||||
)}
|
||||
{activeView === 'agent-tools' && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-semibold text-foreground">Agent Tools</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Configure and manage agent tools - Coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeView === 'agent-tools' && <AgentTools />}
|
||||
</>
|
||||
) : (
|
||||
<WelcomeScreen
|
||||
@@ -776,6 +769,13 @@ export function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Add Project Modal */}
|
||||
<AddProjectModal
|
||||
open={showAddProjectModal}
|
||||
onOpenChange={setShowAddProjectModal}
|
||||
onProjectAdded={handleProjectAdded}
|
||||
/>
|
||||
|
||||
{/* Initialize Auto Claude Dialog */}
|
||||
<Dialog open={showInitDialog} onOpenChange={(open) => {
|
||||
console.log('[InitDialog] onOpenChange called', { open, pendingProject: !!pendingProject, isInitializing, initSuccess });
|
||||
@@ -888,6 +888,9 @@ export function App() {
|
||||
|
||||
{/* App Update Notification - shows when new app version is available */}
|
||||
<AppUpdateNotification />
|
||||
|
||||
{/* Global Download Indicator - shows Ollama model download progress */}
|
||||
<GlobalDownloadIndicator />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* Agent Tools Overview
|
||||
*
|
||||
* Displays MCP server and tool configuration for each agent phase.
|
||||
* Helps users understand what tools are available during different execution phases.
|
||||
*/
|
||||
|
||||
import {
|
||||
Server,
|
||||
Wrench,
|
||||
Brain,
|
||||
Code,
|
||||
Search,
|
||||
FileCheck,
|
||||
Lightbulb,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Monitor,
|
||||
Globe,
|
||||
ClipboardList,
|
||||
ListChecks
|
||||
} from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import {
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING,
|
||||
DEFAULT_FEATURE_MODELS,
|
||||
DEFAULT_FEATURE_THINKING,
|
||||
AVAILABLE_MODELS,
|
||||
THINKING_LEVELS
|
||||
} from '../../shared/constants/models';
|
||||
import type { ModelTypeShort, ThinkingLevel } from '../../shared/types/settings';
|
||||
|
||||
// Agent configuration data - mirrors AGENT_CONFIGS from backend
|
||||
// Model and thinking are now dynamically read from user settings
|
||||
interface AgentConfig {
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tools: string[];
|
||||
mcp_servers: string[];
|
||||
mcp_optional?: string[];
|
||||
// Maps to settings source - either a phase or a feature
|
||||
settingsSource: {
|
||||
type: 'phase';
|
||||
phase: 'spec' | 'planning' | 'coding' | 'qa';
|
||||
} | {
|
||||
type: 'feature';
|
||||
feature: 'insights' | 'ideation' | 'roadmap' | 'githubIssues' | 'githubPrs';
|
||||
} | {
|
||||
type: 'fixed'; // For agents not yet configurable
|
||||
model: ModelTypeShort;
|
||||
thinking: ThinkingLevel;
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to get model label from short name
|
||||
function getModelLabel(modelShort: ModelTypeShort): string {
|
||||
const model = AVAILABLE_MODELS.find(m => m.value === modelShort);
|
||||
return model?.label.replace('Claude ', '') || modelShort;
|
||||
}
|
||||
|
||||
// Helper to get thinking label from level
|
||||
function getThinkingLabel(level: ThinkingLevel): string {
|
||||
const thinking = THINKING_LEVELS.find(t => t.value === level);
|
||||
return thinking?.label || level;
|
||||
}
|
||||
|
||||
const AGENT_CONFIGS: Record<string, AgentConfig> = {
|
||||
// Spec Creation Phases - all use 'spec' phase settings
|
||||
spec_gatherer: {
|
||||
label: 'Spec Gatherer',
|
||||
description: 'Collects initial requirements from user',
|
||||
category: 'spec',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: [],
|
||||
settingsSource: { type: 'phase', phase: 'spec' },
|
||||
},
|
||||
spec_researcher: {
|
||||
label: 'Spec Researcher',
|
||||
description: 'Validates external integrations and APIs',
|
||||
category: 'spec',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: ['context7'],
|
||||
settingsSource: { type: 'phase', phase: 'spec' },
|
||||
},
|
||||
spec_writer: {
|
||||
label: 'Spec Writer',
|
||||
description: 'Creates the spec.md document',
|
||||
category: 'spec',
|
||||
tools: ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash'],
|
||||
mcp_servers: [],
|
||||
settingsSource: { type: 'phase', phase: 'spec' },
|
||||
},
|
||||
spec_critic: {
|
||||
label: 'Spec Critic',
|
||||
description: 'Self-critique using deep analysis',
|
||||
category: 'spec',
|
||||
tools: ['Read', 'Glob', 'Grep'],
|
||||
mcp_servers: [],
|
||||
settingsSource: { type: 'phase', phase: 'spec' },
|
||||
},
|
||||
spec_discovery: {
|
||||
label: 'Spec Discovery',
|
||||
description: 'Initial project discovery and analysis',
|
||||
category: 'spec',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: [],
|
||||
settingsSource: { type: 'phase', phase: 'spec' },
|
||||
},
|
||||
spec_context: {
|
||||
label: 'Spec Context',
|
||||
description: 'Builds context from existing codebase',
|
||||
category: 'spec',
|
||||
tools: ['Read', 'Glob', 'Grep'],
|
||||
mcp_servers: [],
|
||||
settingsSource: { type: 'phase', phase: 'spec' },
|
||||
},
|
||||
spec_validation: {
|
||||
label: 'Spec Validation',
|
||||
description: 'Validates spec completeness and quality',
|
||||
category: 'spec',
|
||||
tools: ['Read', 'Glob', 'Grep'],
|
||||
mcp_servers: [],
|
||||
settingsSource: { type: 'phase', phase: 'spec' },
|
||||
},
|
||||
|
||||
// Build Phases
|
||||
planner: {
|
||||
label: 'Planner',
|
||||
description: 'Creates implementation plan with subtasks',
|
||||
category: 'build',
|
||||
tools: ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: ['context7', 'graphiti-memory', 'auto-claude'],
|
||||
mcp_optional: ['linear'],
|
||||
settingsSource: { type: 'phase', phase: 'planning' },
|
||||
},
|
||||
coder: {
|
||||
label: 'Coder',
|
||||
description: 'Implements individual subtasks',
|
||||
category: 'build',
|
||||
tools: ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: ['context7', 'graphiti-memory', 'auto-claude'],
|
||||
mcp_optional: ['linear'],
|
||||
settingsSource: { type: 'phase', phase: 'coding' },
|
||||
},
|
||||
|
||||
// QA Phases
|
||||
qa_reviewer: {
|
||||
label: 'QA Reviewer',
|
||||
description: 'Validates acceptance criteria. Uses Electron or Puppeteer based on project type.',
|
||||
category: 'qa',
|
||||
tools: ['Read', 'Glob', 'Grep', 'Bash', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: ['context7', 'graphiti-memory', 'auto-claude'],
|
||||
mcp_optional: ['linear', 'electron', 'puppeteer'],
|
||||
settingsSource: { type: 'phase', phase: 'qa' },
|
||||
},
|
||||
qa_fixer: {
|
||||
label: 'QA Fixer',
|
||||
description: 'Fixes QA-reported issues. Uses Electron or Puppeteer based on project type.',
|
||||
category: 'qa',
|
||||
tools: ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: ['context7', 'graphiti-memory', 'auto-claude'],
|
||||
mcp_optional: ['linear', 'electron', 'puppeteer'],
|
||||
settingsSource: { type: 'phase', phase: 'qa' },
|
||||
},
|
||||
|
||||
// Utility Phases - use feature settings
|
||||
pr_reviewer: {
|
||||
label: 'PR Reviewer',
|
||||
description: 'Reviews GitHub pull requests',
|
||||
category: 'utility',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: ['context7'],
|
||||
settingsSource: { type: 'feature', feature: 'githubPrs' },
|
||||
},
|
||||
commit_message: {
|
||||
label: 'Commit Message',
|
||||
description: 'Generates commit messages',
|
||||
category: 'utility',
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
// Commit message uses Haiku for speed - not yet user-configurable
|
||||
settingsSource: { type: 'fixed', model: 'haiku', thinking: 'low' },
|
||||
},
|
||||
merge_resolver: {
|
||||
label: 'Merge Resolver',
|
||||
description: 'Resolves merge conflicts',
|
||||
category: 'utility',
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
// Merge resolver uses Haiku - not yet user-configurable
|
||||
settingsSource: { type: 'fixed', model: 'haiku', thinking: 'low' },
|
||||
},
|
||||
insights: {
|
||||
label: 'Insights',
|
||||
description: 'Extracts code insights',
|
||||
category: 'utility',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: [],
|
||||
settingsSource: { type: 'feature', feature: 'insights' },
|
||||
},
|
||||
analysis: {
|
||||
label: 'Analysis',
|
||||
description: 'Codebase analysis with context lookup',
|
||||
category: 'utility',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: ['context7'],
|
||||
// Analysis uses same as insights
|
||||
settingsSource: { type: 'feature', feature: 'insights' },
|
||||
},
|
||||
batch_analysis: {
|
||||
label: 'Batch Analysis',
|
||||
description: 'Batch processing of issues or items',
|
||||
category: 'utility',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: [],
|
||||
// Batch uses same as GitHub Issues
|
||||
settingsSource: { type: 'feature', feature: 'githubIssues' },
|
||||
},
|
||||
|
||||
// Ideation & Roadmap - use feature settings
|
||||
ideation: {
|
||||
label: 'Ideation',
|
||||
description: 'Generates feature ideas',
|
||||
category: 'ideation',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: [],
|
||||
settingsSource: { type: 'feature', feature: 'ideation' },
|
||||
},
|
||||
roadmap_discovery: {
|
||||
label: 'Roadmap Discovery',
|
||||
description: 'Discovers roadmap items',
|
||||
category: 'ideation',
|
||||
tools: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch'],
|
||||
mcp_servers: ['context7'],
|
||||
settingsSource: { type: 'feature', feature: 'roadmap' },
|
||||
},
|
||||
};
|
||||
|
||||
// MCP Server descriptions - accurate per backend models.py
|
||||
const MCP_SERVERS: Record<string, { name: string; description: string; icon: React.ElementType; tools?: string[] }> = {
|
||||
context7: {
|
||||
name: 'Context7',
|
||||
description: 'Documentation lookup for libraries and frameworks via @upstash/context7-mcp',
|
||||
icon: Search,
|
||||
tools: ['mcp__context7__resolve-library-id', 'mcp__context7__get-library-docs'],
|
||||
},
|
||||
'graphiti-memory': {
|
||||
name: 'Graphiti Memory',
|
||||
description: 'Knowledge graph for cross-session context. Requires GRAPHITI_MCP_URL env var.',
|
||||
icon: Brain,
|
||||
tools: [
|
||||
'mcp__graphiti-memory__search_nodes',
|
||||
'mcp__graphiti-memory__search_facts',
|
||||
'mcp__graphiti-memory__add_episode',
|
||||
'mcp__graphiti-memory__get_episodes',
|
||||
'mcp__graphiti-memory__get_entity_edge',
|
||||
],
|
||||
},
|
||||
'auto-claude': {
|
||||
name: 'Auto-Claude Tools',
|
||||
description: 'Build progress tracking, session context, discoveries & gotchas recording',
|
||||
icon: ListChecks,
|
||||
tools: [
|
||||
'mcp__auto-claude__update_subtask_status',
|
||||
'mcp__auto-claude__get_build_progress',
|
||||
'mcp__auto-claude__record_discovery',
|
||||
'mcp__auto-claude__record_gotcha',
|
||||
'mcp__auto-claude__get_session_context',
|
||||
'mcp__auto-claude__update_qa_status',
|
||||
],
|
||||
},
|
||||
linear: {
|
||||
name: 'Linear',
|
||||
description: 'Project management via Linear API. Requires LINEAR_API_KEY env var.',
|
||||
icon: ClipboardList,
|
||||
tools: [
|
||||
'mcp__linear-server__list_teams',
|
||||
'mcp__linear-server__list_projects',
|
||||
'mcp__linear-server__list_issues',
|
||||
'mcp__linear-server__create_issue',
|
||||
'mcp__linear-server__update_issue',
|
||||
// ... and more
|
||||
],
|
||||
},
|
||||
electron: {
|
||||
name: 'Electron MCP',
|
||||
description: 'Desktop app automation via Chrome DevTools Protocol. Requires ELECTRON_MCP_ENABLED=true.',
|
||||
icon: Monitor,
|
||||
tools: [
|
||||
'mcp__electron__get_electron_window_info',
|
||||
'mcp__electron__take_screenshot',
|
||||
'mcp__electron__send_command_to_electron',
|
||||
'mcp__electron__read_electron_logs',
|
||||
],
|
||||
},
|
||||
puppeteer: {
|
||||
name: 'Puppeteer MCP',
|
||||
description: 'Web browser automation for non-Electron web frontends.',
|
||||
icon: Globe,
|
||||
tools: [
|
||||
'mcp__puppeteer__puppeteer_connect_active_tab',
|
||||
'mcp__puppeteer__puppeteer_navigate',
|
||||
'mcp__puppeteer__puppeteer_screenshot',
|
||||
'mcp__puppeteer__puppeteer_click',
|
||||
'mcp__puppeteer__puppeteer_fill',
|
||||
'mcp__puppeteer__puppeteer_select',
|
||||
'mcp__puppeteer__puppeteer_hover',
|
||||
'mcp__puppeteer__puppeteer_evaluate',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Category metadata - neutral styling per design.json
|
||||
const CATEGORIES = {
|
||||
spec: { label: 'Spec Creation', icon: FileCheck },
|
||||
build: { label: 'Build', icon: Code },
|
||||
qa: { label: 'QA', icon: CheckCircle2 },
|
||||
utility: { label: 'Utility', icon: Wrench },
|
||||
ideation: { label: 'Ideation', icon: Lightbulb },
|
||||
};
|
||||
|
||||
interface AgentCardProps {
|
||||
id: string;
|
||||
config: typeof AGENT_CONFIGS[keyof typeof AGENT_CONFIGS];
|
||||
modelLabel: string;
|
||||
thinkingLabel: string;
|
||||
}
|
||||
|
||||
function AgentCard({ config, modelLabel, thinkingLabel }: AgentCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const category = CATEGORIES[config.category as keyof typeof CATEGORIES];
|
||||
const CategoryIcon = category.icon;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
||||
{/* Header - clickable to expand */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<CategoryIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-medium text-sm text-foreground">{config.label}</h3>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-medium bg-secondary text-secondary-foreground">
|
||||
{modelLabel}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-medium bg-secondary text-secondary-foreground">
|
||||
{thinkingLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{config.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs">
|
||||
{config.mcp_servers.length + (config.mcp_optional?.length || 0)} MCP
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
||||
{/* MCP Servers */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||
MCP Servers
|
||||
</h4>
|
||||
{config.mcp_servers.length > 0 || (config.mcp_optional && config.mcp_optional.length > 0) ? (
|
||||
<div className="space-y-3">
|
||||
{config.mcp_servers.map((server) => {
|
||||
const serverInfo = MCP_SERVERS[server];
|
||||
const ServerIcon = serverInfo?.icon || Server;
|
||||
return (
|
||||
<div key={server} className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<ServerIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{serverInfo?.name || server}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
{serverInfo?.description}
|
||||
</p>
|
||||
{serverInfo?.tools && (
|
||||
<div className="ml-6 flex flex-wrap gap-1">
|
||||
{serverInfo.tools.slice(0, 3).map((tool) => (
|
||||
<span key={tool} className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{tool.replace('mcp__', '').replace(/__/g, ':')}
|
||||
</span>
|
||||
))}
|
||||
{serverInfo.tools.length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{serverInfo.tools.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{config.mcp_optional?.map((server) => {
|
||||
const serverInfo = MCP_SERVERS[server];
|
||||
const ServerIcon = serverInfo?.icon || Server;
|
||||
return (
|
||||
<div key={server} className="space-y-1 opacity-60">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Circle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<ServerIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{serverInfo?.name || server}</span>
|
||||
<span className="text-[10px] text-muted-foreground">(conditional)</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
{serverInfo?.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No MCP servers required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Available Tools
|
||||
</h4>
|
||||
{config.tools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{config.tools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="px-2 py-1 bg-muted rounded text-xs font-mono"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Text-only (no tools)</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentTools() {
|
||||
const settings = useSettingsStore((state) => state.settings);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(['spec', 'build', 'qa'])
|
||||
);
|
||||
|
||||
// Get phase and feature settings with defaults
|
||||
const phaseModels = settings.customPhaseModels || DEFAULT_PHASE_MODELS;
|
||||
const phaseThinking = settings.customPhaseThinking || DEFAULT_PHASE_THINKING;
|
||||
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
|
||||
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
|
||||
|
||||
// Resolve model and thinking for an agent based on its settings source
|
||||
const resolveAgentSettings = useMemo(() => {
|
||||
return (config: AgentConfig): { model: ModelTypeShort; thinking: ThinkingLevel } => {
|
||||
const source = config.settingsSource;
|
||||
|
||||
if (source.type === 'phase') {
|
||||
return {
|
||||
model: phaseModels[source.phase],
|
||||
thinking: phaseThinking[source.phase],
|
||||
};
|
||||
} else if (source.type === 'feature') {
|
||||
return {
|
||||
model: featureModels[source.feature],
|
||||
thinking: featureThinking[source.feature],
|
||||
};
|
||||
} else {
|
||||
// Fixed settings
|
||||
return {
|
||||
model: source.model,
|
||||
thinking: source.thinking,
|
||||
};
|
||||
}
|
||||
};
|
||||
}, [phaseModels, phaseThinking, featureModels, featureThinking]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Group agents by category
|
||||
const agentsByCategory = Object.entries(AGENT_CONFIGS).reduce(
|
||||
(acc, [id, config]) => {
|
||||
const category = config.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push({ id, config });
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Array<{ id: string; config: typeof AGENT_CONFIGS[keyof typeof AGENT_CONFIGS] }>>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">MCP Server Overview</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View which MCP servers and tools are available for each agent phase
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* MCP Server Legend */}
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h2 className="text-sm font-medium text-foreground mb-3">Available MCP Servers</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(MCP_SERVERS).map(([id, server]) => {
|
||||
const Icon = server.icon;
|
||||
return (
|
||||
<div key={id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium">{server.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">{server.description}</p>
|
||||
{server.tools && (
|
||||
<div className="ml-6 text-xs text-muted-foreground">
|
||||
{server.tools.length} tools available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Categories */}
|
||||
{Object.entries(CATEGORIES).map(([categoryId, category]) => {
|
||||
const agents = agentsByCategory[categoryId] || [];
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
const isExpanded = expandedCategories.has(categoryId);
|
||||
const CategoryIcon = category.icon;
|
||||
|
||||
return (
|
||||
<div key={categoryId} className="space-y-3">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(categoryId)}
|
||||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<CategoryIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{category.label}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({agents.length} agents)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Agent Cards */}
|
||||
{isExpanded && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 pl-6">
|
||||
{agents.map(({ id, config }) => {
|
||||
const { model, thinking } = resolveAgentSettings(config);
|
||||
return (
|
||||
<AgentCard
|
||||
key={id}
|
||||
id={id}
|
||||
config={config}
|
||||
modelLabel={getModelLabel(model)}
|
||||
thinkingLabel={getThinkingLabel(thinking)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import { Download, X, ChevronDown, ChevronUp, Check, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDownloadStore } from '../stores/download-store';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
/**
|
||||
* GlobalDownloadIndicator Component
|
||||
*
|
||||
* A floating indicator that shows active Ollama model downloads.
|
||||
* Appears in the bottom-right corner when downloads are in progress.
|
||||
* Can be expanded to show details or minimized to just show count.
|
||||
*/
|
||||
export function GlobalDownloadIndicator() {
|
||||
const { t } = useTranslation('common');
|
||||
const downloads = useDownloadStore((state) => state.downloads);
|
||||
const clearDownload = useDownloadStore((state) => state.clearDownload);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const allDownloads = Object.values(downloads);
|
||||
const activeDownloads = allDownloads.filter(
|
||||
(d) => d.status === 'starting' || d.status === 'downloading'
|
||||
);
|
||||
const completedDownloads = allDownloads.filter((d) => d.status === 'completed');
|
||||
const failedDownloads = allDownloads.filter((d) => d.status === 'failed');
|
||||
|
||||
// Don't render if no downloads
|
||||
if (allDownloads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasActive = activeDownloads.length > 0;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-sm">
|
||||
<div className="rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2 cursor-pointer w-full text-left',
|
||||
hasActive ? 'bg-primary/10' : 'bg-muted/50'
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={t('downloads.toggleExpand')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasActive ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
) : completedDownloads.length > 0 && failedDownloads.length === 0 ? (
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
) : failedDownloads.length > 0 ? (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{hasActive
|
||||
? t('downloads.downloading', { count: activeDownloads.length })
|
||||
: completedDownloads.length > 0
|
||||
? t('downloads.complete', { count: completedDownloads.length })
|
||||
: t('downloads.failed', { count: failedDownloads.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!hasActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Clear all completed/failed downloads
|
||||
allDownloads.forEach((d) => {
|
||||
if (d.status === 'completed' || d.status === 'failed') {
|
||||
clearDownload(d.modelName);
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
aria-label={t('downloads.clearAll')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Download list (expanded) */}
|
||||
{isExpanded && (
|
||||
<div className="divide-y divide-border">
|
||||
{allDownloads.map((download) => (
|
||||
<div key={download.modelName} className="px-3 py-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium truncate max-w-[200px]">
|
||||
{download.modelName}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{download.status === 'completed' && (
|
||||
<span className="text-xs text-success flex items-center gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
{t('downloads.done')}
|
||||
</span>
|
||||
)}
|
||||
{download.status === 'failed' && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{t('downloads.failedLabel')}
|
||||
</span>
|
||||
)}
|
||||
{(download.status === 'starting' || download.status === 'downloading') && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{download.percentage > 0 ? `${Math.round(download.percentage)}%` : t('downloads.starting')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar for active downloads */}
|
||||
{(download.status === 'starting' || download.status === 'downloading') && (
|
||||
<>
|
||||
<div className="w-full bg-muted rounded-full h-1.5 overflow-hidden">
|
||||
{download.percentage > 0 ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${download.percentage}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-1/4 rounded-full bg-primary animate-indeterminate" />
|
||||
)}
|
||||
</div>
|
||||
{(download.speed || download.timeRemaining) && (
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{download.speed || ''}</span>
|
||||
<span className="text-primary">{download.timeRemaining || ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error message for failed downloads */}
|
||||
{download.status === 'failed' && download.error && (
|
||||
<p className="text-[10px] text-destructive/80 truncate">{download.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelLeft
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Button } from './ui/button';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
@@ -44,6 +46,44 @@ import {
|
||||
TASK_COMPLEXITY_COLORS
|
||||
} from '../../shared/constants';
|
||||
|
||||
// Safe link renderer for ReactMarkdown to prevent phishing and ensure external links open safely
|
||||
const SafeLink = ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
// Validate URL - only allow http, https, and relative links
|
||||
const isValidUrl = href && (
|
||||
href.startsWith('http://') ||
|
||||
href.startsWith('https://') ||
|
||||
href.startsWith('/') ||
|
||||
href.startsWith('#')
|
||||
);
|
||||
|
||||
if (!isValidUrl) {
|
||||
// For invalid or potentially malicious URLs, render as plain text
|
||||
return <span className="text-muted-foreground">{children}</span>;
|
||||
}
|
||||
|
||||
// External links get security attributes
|
||||
const isExternal = href?.startsWith('http://') || href?.startsWith('https://');
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...props}
|
||||
{...(isExternal && {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
})}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
// Markdown components with safe link rendering
|
||||
const markdownComponents = {
|
||||
a: SafeLink,
|
||||
};
|
||||
|
||||
interface InsightsProps {
|
||||
projectId: string;
|
||||
}
|
||||
@@ -273,7 +313,9 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
</div>
|
||||
{streamingContent && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="whitespace-pre-wrap">{streamingContent}</p>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{streamingContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{/* Tool usage indicator */}
|
||||
@@ -377,7 +419,9 @@ function MessageBubble({
|
||||
{isUser ? 'You' : 'Assistant'}
|
||||
</div>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* Tool usage history for assistant messages */}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
FileText,
|
||||
Sparkles,
|
||||
GitBranch,
|
||||
HelpCircle
|
||||
HelpCircle,
|
||||
Wrench
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
@@ -77,7 +78,8 @@ const projectNavItems: NavItem[] = [
|
||||
const toolsNavItems: NavItem[] = [
|
||||
{ id: 'github-issues', labelKey: 'navigation:items.githubIssues', icon: Github, shortcut: 'G' },
|
||||
{ id: 'github-prs', labelKey: 'navigation:items.githubPRs', icon: GitPullRequest, shortcut: 'P' },
|
||||
{ id: 'worktrees', labelKey: 'navigation:items.worktrees', icon: GitBranch, shortcut: 'W' }
|
||||
{ id: 'worktrees', labelKey: 'navigation:items.worktrees', icon: GitBranch, shortcut: 'W' },
|
||||
{ id: 'agent-tools', labelKey: 'navigation:items.agentTools', icon: Wrench, shortcut: 'M' }
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
|
||||
@@ -19,7 +19,8 @@ export function Terminal({
|
||||
onClose,
|
||||
onActivate,
|
||||
tasks = [],
|
||||
onNewTaskClick
|
||||
onNewTaskClick,
|
||||
terminalCount = 1
|
||||
}: TerminalProps) {
|
||||
const isMountedRef = useRef(true);
|
||||
const isCreatedRef = useRef(false);
|
||||
@@ -184,6 +185,7 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
onTaskSelect={handleTaskSelect}
|
||||
onClearTask={handleClearTask}
|
||||
onNewTaskClick={onNewTaskClick}
|
||||
terminalCount={terminalCount}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -421,6 +421,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }:
|
||||
onActivate={() => setActiveTerminal(terminal.id)}
|
||||
tasks={tasks}
|
||||
onNewTaskClick={onNewTaskClick}
|
||||
terminalCount={terminals.length}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -97,7 +97,13 @@ export function useIdeation(projectId: string, options: UseIdeationOptions = {})
|
||||
|
||||
const getAvailableTypesToAdd = (): IdeationType[] => {
|
||||
if (!session) return ALL_IDEATION_TYPES;
|
||||
const existingTypes = new Set(session.ideas.map((idea) => idea.type));
|
||||
// Only count types with active ideas (not dismissed or archived)
|
||||
// This allows users to regenerate types where all ideas were dismissed
|
||||
const existingTypes = new Set(
|
||||
session.ideas
|
||||
.filter((idea) => idea.status !== 'dismissed' && idea.status !== 'archived')
|
||||
.map((idea) => idea.type)
|
||||
);
|
||||
return ALL_IDEATION_TYPES.filter((type) => !existingTypes.has(type));
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Code, Terminal, Loader2, Check, RefreshCw, Info } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Label } from '../ui/label';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '../ui/select';
|
||||
import { Input } from '../ui/input';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import type { SupportedIDE, SupportedTerminal } from '../../../shared/types';
|
||||
|
||||
interface DevToolsStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface DetectedTool {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
interface DetectedTools {
|
||||
ides: DetectedTool[];
|
||||
terminals: DetectedTool[];
|
||||
}
|
||||
|
||||
// IDE display names - alphabetically sorted for easy scanning
|
||||
const IDE_NAMES: Partial<Record<SupportedIDE, string>> = {
|
||||
androidstudio: 'Android Studio',
|
||||
clion: 'CLion',
|
||||
cursor: 'Cursor',
|
||||
emacs: 'Emacs',
|
||||
goland: 'GoLand',
|
||||
intellij: 'IntelliJ IDEA',
|
||||
neovim: 'Neovim',
|
||||
nova: 'Nova',
|
||||
phpstorm: 'PhpStorm',
|
||||
pycharm: 'PyCharm',
|
||||
rider: 'Rider',
|
||||
rubymine: 'RubyMine',
|
||||
sublime: 'Sublime Text',
|
||||
vim: 'Vim',
|
||||
vscode: 'Visual Studio Code',
|
||||
vscodium: 'VSCodium',
|
||||
webstorm: 'WebStorm',
|
||||
windsurf: 'Windsurf',
|
||||
xcode: 'Xcode',
|
||||
zed: 'Zed',
|
||||
custom: 'Custom...' // Always last
|
||||
};
|
||||
|
||||
// Terminal display names - alphabetically sorted
|
||||
const TERMINAL_NAMES: Partial<Record<SupportedTerminal, string>> = {
|
||||
alacritty: 'Alacritty',
|
||||
ghostty: 'Ghostty',
|
||||
gnometerminal: 'GNOME Terminal',
|
||||
hyper: 'Hyper',
|
||||
iterm2: 'iTerm2',
|
||||
kitty: 'Kitty',
|
||||
konsole: 'Konsole',
|
||||
powershell: 'PowerShell',
|
||||
system: 'System Terminal',
|
||||
tabby: 'Tabby',
|
||||
terminal: 'Terminal.app',
|
||||
terminator: 'Terminator',
|
||||
tilix: 'Tilix',
|
||||
tmux: 'tmux',
|
||||
warp: 'Warp',
|
||||
wezterm: 'WezTerm',
|
||||
windowsterminal: 'Windows Terminal',
|
||||
zellij: 'Zellij',
|
||||
custom: 'Custom...' // Always last
|
||||
};
|
||||
|
||||
/**
|
||||
* Developer Tools configuration step for the onboarding wizard.
|
||||
*
|
||||
* Detects installed IDEs and terminals, allows the user to select
|
||||
* their preferred tools for opening worktrees.
|
||||
*/
|
||||
export function DevToolsStep({ onNext, onBack }: DevToolsStepProps) {
|
||||
const { settings, updateSettings } = useSettingsStore();
|
||||
const [preferredIDE, setPreferredIDE] = useState<SupportedIDE>(settings.preferredIDE || 'vscode');
|
||||
const [preferredTerminal, setPreferredTerminal] = useState<SupportedTerminal>(settings.preferredTerminal || 'system');
|
||||
const [customIDEPath, setCustomIDEPath] = useState(settings.customIDEPath || '');
|
||||
const [customTerminalPath, setCustomTerminalPath] = useState(settings.customTerminalPath || '');
|
||||
|
||||
const [detectedTools, setDetectedTools] = useState<DetectedTools | null>(null);
|
||||
const [isDetecting, setIsDetecting] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Detect installed tools on mount
|
||||
const detectTools = useCallback(async () => {
|
||||
setIsDetecting(true);
|
||||
try {
|
||||
// Check if the API is available (may not be in dev mode or if preload failed)
|
||||
if (!window.electronAPI?.worktreeDetectTools) {
|
||||
console.warn('[DevToolsStep] Detection API not available, using fallback');
|
||||
setIsDetecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.worktreeDetectTools();
|
||||
if (result.success && result.data) {
|
||||
setDetectedTools(result.data as DetectedTools);
|
||||
|
||||
// Auto-select the first detected IDE if none is configured
|
||||
if (!settings.preferredIDE && result.data.ides.length > 0) {
|
||||
setPreferredIDE(result.data.ides[0].id as SupportedIDE);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to detect tools:', err);
|
||||
} finally {
|
||||
setIsDetecting(false);
|
||||
}
|
||||
}, [settings.preferredIDE]);
|
||||
|
||||
useEffect(() => {
|
||||
detectTools();
|
||||
}, [detectTools]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const settingsToSave = {
|
||||
preferredIDE,
|
||||
preferredTerminal,
|
||||
customIDEPath: preferredIDE === 'custom' ? customIDEPath : undefined,
|
||||
customTerminalPath: preferredTerminal === 'custom' ? customTerminalPath : undefined
|
||||
};
|
||||
|
||||
const result = await window.electronAPI.saveSettings(settingsToSave);
|
||||
|
||||
if (result?.success) {
|
||||
updateSettings(settingsToSave);
|
||||
onNext();
|
||||
} else {
|
||||
setError(result?.error || 'Failed to save settings');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build IDE options with detection status
|
||||
const ideOptions: Array<{ value: SupportedIDE; label: string; detected: boolean }> = [];
|
||||
|
||||
// Add detected IDEs first
|
||||
if (detectedTools) {
|
||||
for (const tool of detectedTools.ides) {
|
||||
ideOptions.push({
|
||||
value: tool.id as SupportedIDE,
|
||||
label: tool.name,
|
||||
detected: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining IDEs that weren't detected
|
||||
const detectedIDEIds = new Set(detectedTools?.ides.map(t => t.id) || []);
|
||||
for (const [id, name] of Object.entries(IDE_NAMES)) {
|
||||
if (id !== 'custom' && !detectedIDEIds.has(id)) {
|
||||
ideOptions.push({
|
||||
value: id as SupportedIDE,
|
||||
label: name,
|
||||
detected: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom option last
|
||||
ideOptions.push({ value: 'custom', label: 'Custom...', detected: false });
|
||||
|
||||
// Build Terminal options with detection status
|
||||
const terminalOptions: Array<{ value: SupportedTerminal; label: string; detected: boolean }> = [];
|
||||
|
||||
// Always add system terminal first
|
||||
terminalOptions.push({
|
||||
value: 'system',
|
||||
label: TERMINAL_NAMES.system || 'System Terminal',
|
||||
detected: true
|
||||
});
|
||||
|
||||
// Add detected terminals
|
||||
if (detectedTools) {
|
||||
for (const tool of detectedTools.terminals) {
|
||||
if (tool.id !== 'system') {
|
||||
terminalOptions.push({
|
||||
value: tool.id as SupportedTerminal,
|
||||
label: tool.name,
|
||||
detected: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining terminals that weren't detected
|
||||
const detectedTerminalIds = new Set(detectedTools?.terminals.map(t => t.id) || []);
|
||||
detectedTerminalIds.add('system');
|
||||
for (const [id, name] of Object.entries(TERMINAL_NAMES)) {
|
||||
if (id !== 'custom' && !detectedTerminalIds.has(id)) {
|
||||
terminalOptions.push({
|
||||
value: id as SupportedTerminal,
|
||||
label: name,
|
||||
detected: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom option last
|
||||
terminalOptions.push({ value: 'custom', label: 'Custom...', detected: false });
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-8 py-6">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Code className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground tracking-tight">
|
||||
Developer Tools
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Choose your preferred IDE and terminal for working with Auto Claude worktrees
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isDetecting && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Detecting installed tools...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
{!isDetecting && (
|
||||
<div className="space-y-6">
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<Card className="border border-destructive/30 bg-destructive/10">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Info card */}
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Why configure these?
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When Auto Claude builds features in isolated worktrees, you can open them
|
||||
directly in your preferred IDE or terminal to test and review changes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detect Again Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={detectTools}
|
||||
disabled={isDetecting}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Detect Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* IDE Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
Preferred IDE
|
||||
</Label>
|
||||
<Select
|
||||
value={preferredIDE}
|
||||
onValueChange={(value: SupportedIDE) => setPreferredIDE(value)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select IDE..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ideOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{option.label}</span>
|
||||
{option.detected && (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto Claude will open worktrees in this editor
|
||||
</p>
|
||||
|
||||
{/* Custom IDE Path */}
|
||||
{preferredIDE === 'custom' && (
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="custom-ide-path" className="text-xs text-muted-foreground">
|
||||
Custom IDE Path
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-ide-path"
|
||||
value={customIDEPath}
|
||||
onChange={(e) => setCustomIDEPath(e.target.value)}
|
||||
placeholder="/path/to/your/ide"
|
||||
className="mt-1"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
Preferred Terminal
|
||||
</Label>
|
||||
<Select
|
||||
value={preferredTerminal}
|
||||
onValueChange={(value: SupportedTerminal) => setPreferredTerminal(value)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select terminal..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{terminalOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{option.label}</span>
|
||||
{option.detected && (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto Claude will open terminal sessions here
|
||||
</p>
|
||||
|
||||
{/* Custom Terminal Path */}
|
||||
{preferredTerminal === 'custom' && (
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="custom-terminal-path" className="text-xs text-muted-foreground">
|
||||
Custom Terminal Path
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-terminal-path"
|
||||
value={customTerminalPath}
|
||||
onChange={(e) => setCustomTerminalPath(e.target.value)}
|
||||
placeholder="/path/to/your/terminal"
|
||||
className="mt-1"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detection Summary */}
|
||||
{detectedTools && (
|
||||
<div className="text-xs text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
<p className="font-medium mb-1">Detected on your system:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{detectedTools.ides.map((ide) => (
|
||||
<li key={ide.id}>{ide.name}</li>
|
||||
))}
|
||||
{detectedTools.terminals.filter(t => t.id !== 'system').map((term) => (
|
||||
<li key={term.id}>{term.name}</li>
|
||||
))}
|
||||
{detectedTools.ides.length === 0 && detectedTools.terminals.filter(t => t.id !== 'system').length === 0 && (
|
||||
<li>No additional tools detected (VS Code and system terminal will be used)</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center mt-10 pt-6 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isDetecting || isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save & Continue'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useDownloadStore } from '../../stores/download-store';
|
||||
|
||||
interface OllamaModel {
|
||||
name: string;
|
||||
@@ -15,6 +16,7 @@ interface OllamaModel {
|
||||
size_estimate?: string;
|
||||
dim: number;
|
||||
installed: boolean;
|
||||
badge?: 'recommended' | 'quality' | 'fast';
|
||||
}
|
||||
|
||||
interface OllamaModelSelectorProps {
|
||||
@@ -25,11 +27,35 @@ interface OllamaModelSelectorProps {
|
||||
}
|
||||
|
||||
// Recommended embedding models for Auto Claude Memory
|
||||
// embeddinggemma is first as the recommended default
|
||||
// qwen3-embedding:4b is first as the recommended default (balanced quality/speed)
|
||||
const RECOMMENDED_MODELS: OllamaModel[] = [
|
||||
{
|
||||
name: 'qwen3-embedding:4b',
|
||||
description: 'Qwen3 4B - Balanced quality and speed',
|
||||
size_estimate: '3.1 GB',
|
||||
dim: 2560,
|
||||
installed: false,
|
||||
badge: 'recommended',
|
||||
},
|
||||
{
|
||||
name: 'qwen3-embedding:8b',
|
||||
description: 'Qwen3 8B - Best embedding quality',
|
||||
size_estimate: '6.0 GB',
|
||||
dim: 4096,
|
||||
installed: false,
|
||||
badge: 'quality',
|
||||
},
|
||||
{
|
||||
name: 'qwen3-embedding:0.6b',
|
||||
description: 'Qwen3 0.6B - Smallest and fastest',
|
||||
size_estimate: '494 MB',
|
||||
dim: 1024,
|
||||
installed: false,
|
||||
badge: 'fast',
|
||||
},
|
||||
{
|
||||
name: 'embeddinggemma',
|
||||
description: "Google's lightweight embedding model (Recommended)",
|
||||
description: "Google's lightweight embedding model",
|
||||
size_estimate: '621 MB',
|
||||
dim: 768,
|
||||
installed: false,
|
||||
@@ -41,26 +67,8 @@ const RECOMMENDED_MODELS: OllamaModel[] = [
|
||||
dim: 768,
|
||||
installed: false,
|
||||
},
|
||||
{
|
||||
name: 'mxbai-embed-large',
|
||||
description: 'MixedBread AI large embeddings',
|
||||
size_estimate: '670 MB',
|
||||
dim: 1024,
|
||||
installed: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Progress state for a single model download.
|
||||
* Tracks percentage completion, download speed, and estimated time remaining.
|
||||
*/
|
||||
interface DownloadProgress {
|
||||
[modelName: string]: {
|
||||
percentage: number;
|
||||
speed?: string;
|
||||
timeRemaining?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OllamaModelSelector Component
|
||||
@@ -98,18 +106,14 @@ export function OllamaModelSelector({
|
||||
}: OllamaModelSelectorProps) {
|
||||
const [models, setModels] = useState<OllamaModel[]>(RECOMMENDED_MODELS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDownloading, setIsDownloading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ollamaAvailable, setOllamaAvailable] = useState(true);
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress>({});
|
||||
|
||||
// Track previous progress for speed calculation
|
||||
const downloadProgressRef = useRef<{
|
||||
[modelName: string]: {
|
||||
lastCompleted: number;
|
||||
lastUpdate: number;
|
||||
};
|
||||
}>({});
|
||||
// Use global download store for tracking downloads
|
||||
const downloads = useDownloadStore((state) => state.downloads);
|
||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||
const completeDownload = useDownloadStore((state) => state.completeDownload);
|
||||
const failDownload = useDownloadStore((state) => state.failDownload);
|
||||
|
||||
/**
|
||||
* Checks Ollama service status and fetches list of installed embedding models.
|
||||
@@ -140,21 +144,32 @@ export function OllamaModelSelector({
|
||||
if (abortSignal?.aborted) return;
|
||||
|
||||
if (result?.success && result?.data?.embedding_models) {
|
||||
const installedNames = new Set(
|
||||
result.data.embedding_models.map((m: { name: string }) => {
|
||||
// Normalize: "embeddinggemma:latest" -> "embeddinggemma"
|
||||
const name = m.name;
|
||||
return name.includes(':') ? name.split(':')[0] : name;
|
||||
})
|
||||
);
|
||||
// Build a set of installed model names (both full name and normalized)
|
||||
const installedFullNames = new Set<string>();
|
||||
const installedBaseNames = new Set<string>();
|
||||
|
||||
result.data.embedding_models.forEach((m: { name: string }) => {
|
||||
const name = m.name;
|
||||
installedFullNames.add(name);
|
||||
// Only normalize :latest suffix, not version tags like :4b, :8b, :0.6b
|
||||
if (name.endsWith(':latest')) {
|
||||
installedBaseNames.add(name.replace(':latest', ''));
|
||||
} else if (!name.includes(':')) {
|
||||
installedBaseNames.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
// Update models with installation status
|
||||
setModels(
|
||||
RECOMMENDED_MODELS.map(model => {
|
||||
const baseName = model.name.includes(':') ? model.name.split(':')[0] : model.name;
|
||||
// Check exact match first, then base name (for :latest normalization)
|
||||
const isInstalled = installedFullNames.has(model.name) ||
|
||||
installedBaseNames.has(model.name) ||
|
||||
// Also check if model without tag is installed (e.g., "embeddinggemma" matches "embeddinggemma")
|
||||
(model.name.includes(':') ? false : installedFullNames.has(model.name + ':latest'));
|
||||
return {
|
||||
...model,
|
||||
installed: installedNames.has(baseName) || installedNames.has(model.name),
|
||||
installed: isInstalled,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -178,116 +193,34 @@ export function OllamaModelSelector({
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Progress listener effect:
|
||||
* Subscribes to real-time download progress events from the main process.
|
||||
* Calculates and formats download speed (MB/s, KB/s, B/s) and time remaining.
|
||||
* Uses useRef to track previous state for accurate speed calculations.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleProgress = (data: {
|
||||
modelName: string;
|
||||
status: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}) => {
|
||||
const now = Date.now();
|
||||
|
||||
// Initialize tracking for this model if needed
|
||||
if (!downloadProgressRef.current[data.modelName]) {
|
||||
downloadProgressRef.current[data.modelName] = {
|
||||
lastCompleted: data.completed,
|
||||
lastUpdate: now
|
||||
};
|
||||
}
|
||||
|
||||
const prevData = downloadProgressRef.current[data.modelName];
|
||||
const timeDelta = now - prevData.lastUpdate;
|
||||
const bytesDelta = data.completed - prevData.lastCompleted;
|
||||
|
||||
// Calculate speed only if we have meaningful time delta (> 100ms)
|
||||
let speedStr = '';
|
||||
let timeStr = '';
|
||||
|
||||
if (timeDelta > 100 && bytesDelta > 0) {
|
||||
const speed = (bytesDelta / timeDelta) * 1000; // bytes per second
|
||||
const remaining = data.total - data.completed;
|
||||
const timeRemaining = speed > 0 ? Math.ceil(remaining / speed) : 0;
|
||||
|
||||
// Format speed (MB/s or KB/s)
|
||||
if (speed > 1024 * 1024) {
|
||||
speedStr = `${(speed / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
} else if (speed > 1024) {
|
||||
speedStr = `${(speed / 1024).toFixed(1)} KB/s`;
|
||||
} else if (speed > 0) {
|
||||
speedStr = `${Math.round(speed)} B/s`;
|
||||
}
|
||||
|
||||
// Format time remaining
|
||||
if (timeRemaining > 3600) {
|
||||
timeStr = `${Math.ceil(timeRemaining / 3600)}h remaining`;
|
||||
} else if (timeRemaining > 60) {
|
||||
timeStr = `${Math.ceil(timeRemaining / 60)}m remaining`;
|
||||
} else if (timeRemaining > 0) {
|
||||
timeStr = `${Math.ceil(timeRemaining)}s remaining`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tracking
|
||||
downloadProgressRef.current[data.modelName] = {
|
||||
lastCompleted: data.completed,
|
||||
lastUpdate: now
|
||||
};
|
||||
|
||||
setDownloadProgress(prev => {
|
||||
const updated = { ...prev };
|
||||
updated[data.modelName] = {
|
||||
percentage: data.percentage,
|
||||
speed: speedStr,
|
||||
timeRemaining: timeStr
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Register the progress listener
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
if (window.electronAPI?.onDownloadProgress) {
|
||||
unsubscribe = window.electronAPI.onDownloadProgress(handleProgress);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up listener
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// Progress is now handled globally by the download store listener initialized in App.tsx
|
||||
|
||||
/**
|
||||
* Initiates download of an Ollama embedding model.
|
||||
* Updates UI state during download and refreshes model list after completion.
|
||||
* Uses global download store for state tracking and refreshes model list after completion.
|
||||
*
|
||||
* @param {string} modelName - Name of the model to download (e.g., 'embeddinggemma')
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleDownload = async (modelName: string) => {
|
||||
setIsDownloading(modelName);
|
||||
startDownload(modelName);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.pullOllamaModel(modelName);
|
||||
if (result?.success) {
|
||||
completeDownload(modelName);
|
||||
// Refresh the model list
|
||||
await checkInstalledModels();
|
||||
} else {
|
||||
setError(result?.error || `Failed to download ${modelName}`);
|
||||
const errorMsg = result?.error || `Failed to download ${modelName}`;
|
||||
failDownload(modelName, errorMsg);
|
||||
setError(errorMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Download failed');
|
||||
} finally {
|
||||
setIsDownloading(null);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Download failed';
|
||||
failDownload(modelName, errorMsg);
|
||||
setError(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -348,8 +281,9 @@ export function OllamaModelSelector({
|
||||
<div className="space-y-2">
|
||||
{models.map(model => {
|
||||
const isSelected = selectedModel === model.name;
|
||||
const isCurrentlyDownloading = isDownloading === model.name;
|
||||
const progress = downloadProgress[model.name];
|
||||
const download = downloads[model.name];
|
||||
const isCurrentlyDownloading = download?.status === 'starting' || download?.status === 'downloading';
|
||||
const progress = download;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -386,6 +320,21 @@ export function OllamaModelSelector({
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({model.dim} dim)
|
||||
</span>
|
||||
{model.badge === 'recommended' && (
|
||||
<span className="inline-flex items-center rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
{model.badge === 'quality' && (
|
||||
<span className="inline-flex items-center rounded-full bg-violet-500/15 px-2 py-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
Highest Quality
|
||||
</span>
|
||||
)}
|
||||
{model.badge === 'fast' && (
|
||||
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
Fastest
|
||||
</span>
|
||||
)}
|
||||
{model.installed && (
|
||||
<span className="inline-flex items-center rounded-full bg-success/10 px-2 py-0.5 text-xs text-success">
|
||||
Installed
|
||||
@@ -429,23 +378,28 @@ export function OllamaModelSelector({
|
||||
</div>
|
||||
|
||||
{/* Progress bar for downloading models */}
|
||||
{isCurrentlyDownloading && progress && (
|
||||
{isCurrentlyDownloading && (
|
||||
<div className="px-3 pb-3 space-y-1.5">
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary via-primary to-primary/80 transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, progress.percentage))}%` }}
|
||||
/>
|
||||
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
{progress && progress.percentage > 0 ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary via-primary to-primary/80 transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, progress.percentage))}%` }}
|
||||
/>
|
||||
) : (
|
||||
/* Indeterminate/sliding state while waiting for progress events */
|
||||
<div className="h-full w-1/4 rounded-full bg-gradient-to-r from-primary via-primary to-primary/80 animate-indeterminate" />
|
||||
)}
|
||||
</div>
|
||||
{/* Progress info: percentage, speed, time remaining */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.round(progress.percentage)}%
|
||||
{progress && progress.percentage > 0 ? `${Math.round(progress.percentage)}%` : 'Starting download...'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{progress.speed && <span>{progress.speed}</span>}
|
||||
{progress.timeRemaining && <span className="text-primary">{progress.timeRemaining}</span>}
|
||||
{progress?.speed && <span>{progress.speed}</span>}
|
||||
{progress?.timeRemaining && <span className="text-primary">{progress.timeRemaining}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ScrollArea } from '../ui/scroll-area';
|
||||
import { WizardProgress, WizardStep } from './WizardProgress';
|
||||
import { WelcomeStep } from './WelcomeStep';
|
||||
import { OAuthStep } from './OAuthStep';
|
||||
import { DevToolsStep } from './DevToolsStep';
|
||||
import { MemoryStep } from './MemoryStep';
|
||||
import { CompletionStep } from './CompletionStep';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
@@ -25,12 +26,13 @@ interface OnboardingWizardProps {
|
||||
}
|
||||
|
||||
// Wizard step identifiers
|
||||
type WizardStepId = 'welcome' | 'oauth' | 'memory' | 'completion';
|
||||
type WizardStepId = 'welcome' | 'oauth' | 'devtools' | 'memory' | 'completion';
|
||||
|
||||
// Step configuration with translation keys
|
||||
const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [
|
||||
{ id: 'welcome', labelKey: 'steps.welcome' },
|
||||
{ id: 'oauth', labelKey: 'steps.auth' },
|
||||
{ id: 'devtools', labelKey: 'steps.devtools' },
|
||||
{ id: 'memory', labelKey: 'steps.memory' },
|
||||
{ id: 'completion', labelKey: 'steps.done' }
|
||||
];
|
||||
@@ -155,6 +157,13 @@ export function OnboardingWizard({
|
||||
onSkip={skipWizard}
|
||||
/>
|
||||
);
|
||||
case 'devtools':
|
||||
return (
|
||||
<DevToolsStep
|
||||
onNext={goToNextStep}
|
||||
onBack={goToPreviousStep}
|
||||
/>
|
||||
);
|
||||
case 'memory':
|
||||
return (
|
||||
<MemoryStep
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AlertCircle,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../ui/button';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
@@ -42,6 +43,8 @@ export function GeneralSettings({
|
||||
isUpdating,
|
||||
handleInitialize
|
||||
}: GeneralSettingsProps) {
|
||||
const { t } = useTranslation(['settings']);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Auto-Build Integration */}
|
||||
@@ -127,6 +130,22 @@ export function GeneralSettings({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="font-normal text-foreground">
|
||||
{t('projectSections.general.useClaudeMd')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('projectSections.general.useClaudeMdDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.useClaudeMd ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, useClaudeMd: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -447,7 +447,7 @@ export function MemoryBackendSection({
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Common models: nomic-embed-text, bge-base-en, mxbai-embed-large
|
||||
Recommended: qwen3-embedding:4b (balanced), :8b (quality), :0.6b (fast)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
Database,
|
||||
Sparkles,
|
||||
Monitor,
|
||||
Globe
|
||||
Globe,
|
||||
Code,
|
||||
Bug
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
FullScreenDialog,
|
||||
@@ -37,6 +39,8 @@ import { LanguageSettings } from './LanguageSettings';
|
||||
import { GeneralSettings } from './GeneralSettings';
|
||||
import { IntegrationSettings } from './IntegrationSettings';
|
||||
import { AdvancedSettings } from './AdvancedSettings';
|
||||
import { DevToolsSettings } from './DevToolsSettings';
|
||||
import { DebugSettings } from './DebugSettings';
|
||||
import { ProjectSelector } from './ProjectSelector';
|
||||
import { ProjectSettingsContent, ProjectSettingsSection } from './ProjectSettingsContent';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
@@ -51,7 +55,7 @@ interface AppSettingsDialogProps {
|
||||
}
|
||||
|
||||
// App-level settings sections
|
||||
export type AppSection = 'appearance' | 'display' | 'language' | 'agent' | 'paths' | 'integrations' | 'updates' | 'notifications';
|
||||
export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'integrations' | 'updates' | 'notifications' | 'debug';
|
||||
|
||||
interface NavItemConfig<T extends string> {
|
||||
id: T;
|
||||
@@ -62,11 +66,13 @@ const appNavItemsConfig: NavItemConfig<AppSection>[] = [
|
||||
{ id: 'appearance', icon: Palette },
|
||||
{ id: 'display', icon: Monitor },
|
||||
{ id: 'language', icon: Globe },
|
||||
{ id: 'devtools', icon: Code },
|
||||
{ id: 'agent', icon: Bot },
|
||||
{ id: 'paths', icon: FolderOpen },
|
||||
{ id: 'integrations', icon: Key },
|
||||
{ id: 'updates', icon: Package },
|
||||
{ id: 'notifications', icon: Bell }
|
||||
{ id: 'notifications', icon: Bell },
|
||||
{ id: 'debug', icon: Bug }
|
||||
];
|
||||
|
||||
const projectNavItemsConfig: NavItemConfig<ProjectSettingsSection>[] = [
|
||||
@@ -167,6 +173,8 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
|
||||
return <DisplaySettings settings={settings} onSettingsChange={setSettings} />;
|
||||
case 'language':
|
||||
return <LanguageSettings settings={settings} onSettingsChange={setSettings} />;
|
||||
case 'devtools':
|
||||
return <DevToolsSettings settings={settings} onSettingsChange={setSettings} />;
|
||||
case 'agent':
|
||||
return <GeneralSettings settings={settings} onSettingsChange={setSettings} section="agent" />;
|
||||
case 'paths':
|
||||
@@ -177,6 +185,8 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
|
||||
return <AdvancedSettings settings={settings} onSettingsChange={setSettings} section="updates" version={version} />;
|
||||
case 'notifications':
|
||||
return <AdvancedSettings settings={settings} onSettingsChange={setSettings} section="notifications" version={version} />;
|
||||
case 'debug':
|
||||
return <DebugSettings />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bug, FolderOpen, Copy, FileText, RefreshCw, Loader2, Check, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
|
||||
interface DebugInfo {
|
||||
systemInfo: Record<string, string>;
|
||||
recentErrors: string[];
|
||||
logsPath: string;
|
||||
debugReport: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug settings component for accessing logs and debug information
|
||||
*/
|
||||
export function DebugSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [debugInfo, setDebugInfo] = useState<DebugInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadDebugInfo = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const info = await window.electronAPI.getDebugInfo();
|
||||
setDebugInfo(info);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load debug info');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenLogsFolder = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.openLogsFolder();
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to open logs folder');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to open logs folder');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDebugInfo = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.copyDebugInfo();
|
||||
if (result.success) {
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} else {
|
||||
setError(result.error || 'Failed to copy debug info');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to copy debug info');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('debug.title', 'Debug & Logs')}
|
||||
description={t('debug.description', 'Access logs and debug information for troubleshooting')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenLogsFolder}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{t('debug.openLogsFolder', 'Open Logs Folder')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopyDebugInfo}
|
||||
className="flex items-center gap-2"
|
||||
disabled={copySuccess}
|
||||
>
|
||||
{copySuccess ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
{t('debug.copied', 'Copied!')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
{t('debug.copyDebugInfo', 'Copy Debug Info')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadDebugInfo}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{t('debug.loadInfo', 'Load Debug Info')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Info Display */}
|
||||
{debugInfo && (
|
||||
<div className="space-y-4">
|
||||
{/* System Information */}
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h4 className="font-medium text-sm mb-3 flex items-center gap-2">
|
||||
<Bug className="h-4 w-4" />
|
||||
{t('debug.systemInfo', 'System Information')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{Object.entries(debugInfo.systemInfo).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between gap-2">
|
||||
<span className="text-muted-foreground">{key}:</span>
|
||||
<span className="font-mono text-right truncate" title={value}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Path */}
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h4 className="font-medium text-sm mb-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('debug.logsLocation', 'Logs Location')}
|
||||
</h4>
|
||||
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-1 rounded block truncate">
|
||||
{debugInfo.logsPath}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Recent Errors */}
|
||||
{debugInfo.recentErrors.length > 0 && (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h4 className="font-medium text-sm mb-3 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
{t('debug.recentErrors', 'Recent Errors')} ({debugInfo.recentErrors.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{debugInfo.recentErrors.map((error, index) => (
|
||||
<div key={index} className="text-xs font-mono text-muted-foreground bg-muted/30 px-2 py-1 rounded">
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{debugInfo.recentErrors.length === 0 && (
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
{t('debug.noRecentErrors', 'No recent errors')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 p-3 rounded-md">
|
||||
<p className="font-medium mb-1">{t('debug.helpTitle', 'Reporting Issues')}</p>
|
||||
<p>
|
||||
{t('debug.helpText', 'When reporting bugs, click "Copy Debug Info" to get system information and recent errors that help us diagnose the issue.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Code, Terminal, RefreshCw, Loader2, Check, FolderOpen } from 'lucide-react';
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Button } from '../ui/button';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import type { AppSettings, SupportedIDE, SupportedTerminal } from '../../../shared/types';
|
||||
|
||||
interface DevToolsSettingsProps {
|
||||
settings: AppSettings;
|
||||
onSettingsChange: (settings: AppSettings) => void;
|
||||
}
|
||||
|
||||
interface DetectedTool {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
interface DetectedTools {
|
||||
ides: DetectedTool[];
|
||||
terminals: DetectedTool[];
|
||||
}
|
||||
|
||||
// IDE display names - alphabetically sorted for easy scanning
|
||||
const IDE_NAMES: Partial<Record<SupportedIDE, string>> = {
|
||||
androidstudio: 'Android Studio',
|
||||
clion: 'CLion',
|
||||
cursor: 'Cursor',
|
||||
emacs: 'Emacs',
|
||||
goland: 'GoLand',
|
||||
intellij: 'IntelliJ IDEA',
|
||||
neovim: 'Neovim',
|
||||
nova: 'Nova',
|
||||
phpstorm: 'PhpStorm',
|
||||
pycharm: 'PyCharm',
|
||||
rider: 'Rider',
|
||||
rubymine: 'RubyMine',
|
||||
sublime: 'Sublime Text',
|
||||
vim: 'Vim',
|
||||
vscode: 'Visual Studio Code',
|
||||
vscodium: 'VSCodium',
|
||||
webstorm: 'WebStorm',
|
||||
windsurf: 'Windsurf',
|
||||
xcode: 'Xcode',
|
||||
zed: 'Zed',
|
||||
custom: 'Custom...' // Always last
|
||||
};
|
||||
|
||||
// Terminal display names - alphabetically sorted
|
||||
const TERMINAL_NAMES: Partial<Record<SupportedTerminal, string>> = {
|
||||
alacritty: 'Alacritty',
|
||||
ghostty: 'Ghostty',
|
||||
gnometerminal: 'GNOME Terminal',
|
||||
hyper: 'Hyper',
|
||||
iterm2: 'iTerm2',
|
||||
kitty: 'Kitty',
|
||||
konsole: 'Konsole',
|
||||
powershell: 'PowerShell',
|
||||
system: 'System Terminal',
|
||||
tabby: 'Tabby',
|
||||
terminal: 'Terminal.app',
|
||||
terminator: 'Terminator',
|
||||
tilix: 'Tilix',
|
||||
tmux: 'tmux',
|
||||
warp: 'Warp',
|
||||
wezterm: 'WezTerm',
|
||||
windowsterminal: 'Windows Terminal',
|
||||
zellij: 'Zellij',
|
||||
custom: 'Custom...' // Always last
|
||||
};
|
||||
|
||||
/**
|
||||
* Developer Tools settings component for configuring preferred IDE and terminal
|
||||
*/
|
||||
export function DevToolsSettings({ settings, onSettingsChange }: DevToolsSettingsProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [detectedTools, setDetectedTools] = useState<DetectedTools | null>(null);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [detectError, setDetectError] = useState<string | null>(null);
|
||||
|
||||
// Detect installed tools on mount
|
||||
const detectTools = useCallback(async () => {
|
||||
setIsDetecting(true);
|
||||
setDetectError(null);
|
||||
try {
|
||||
// Check if the API is available (may not be in dev mode or if preload failed)
|
||||
if (!window.electronAPI?.worktreeDetectTools) {
|
||||
console.warn('[DevToolsSettings] Detection API not available');
|
||||
setIsDetecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.worktreeDetectTools();
|
||||
if (result.success && result.data) {
|
||||
setDetectedTools(result.data as DetectedTools);
|
||||
} else {
|
||||
setDetectError(result.error || 'Failed to detect tools');
|
||||
}
|
||||
} catch (err) {
|
||||
setDetectError(err instanceof Error ? err.message : 'Failed to detect tools');
|
||||
} finally {
|
||||
setIsDetecting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
detectTools();
|
||||
}, [detectTools]);
|
||||
|
||||
const handleIDEChange = (ide: SupportedIDE) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
preferredIDE: ide,
|
||||
// Clear custom path when switching away from custom
|
||||
customIDEPath: ide === 'custom' ? settings.customIDEPath : undefined
|
||||
});
|
||||
};
|
||||
|
||||
const handleTerminalChange = (terminal: SupportedTerminal) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
preferredTerminal: terminal,
|
||||
// Clear custom path when switching away from custom
|
||||
customTerminalPath: terminal === 'custom' ? settings.customTerminalPath : undefined
|
||||
});
|
||||
};
|
||||
|
||||
const handleCustomIDEPathChange = (path: string) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
customIDEPath: path
|
||||
});
|
||||
};
|
||||
|
||||
const handleCustomTerminalPathChange = (path: string) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
customTerminalPath: path
|
||||
});
|
||||
};
|
||||
|
||||
// Build IDE options with detection status
|
||||
const ideOptions: Array<{ value: SupportedIDE; label: string; detected: boolean }> = [];
|
||||
|
||||
// Add detected IDEs first
|
||||
if (detectedTools) {
|
||||
for (const tool of detectedTools.ides) {
|
||||
ideOptions.push({
|
||||
value: tool.id as SupportedIDE,
|
||||
label: tool.name,
|
||||
detected: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining IDEs that weren't detected
|
||||
const detectedIDEIds = new Set(detectedTools?.ides.map(t => t.id) || []);
|
||||
for (const [id, name] of Object.entries(IDE_NAMES)) {
|
||||
if (id !== 'custom' && !detectedIDEIds.has(id)) {
|
||||
ideOptions.push({
|
||||
value: id as SupportedIDE,
|
||||
label: name,
|
||||
detected: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom option last
|
||||
ideOptions.push({ value: 'custom', label: 'Custom...', detected: false });
|
||||
|
||||
// Build Terminal options with detection status
|
||||
const terminalOptions: Array<{ value: SupportedTerminal; label: string; detected: boolean }> = [];
|
||||
|
||||
// Always add system terminal first
|
||||
terminalOptions.push({
|
||||
value: 'system',
|
||||
label: TERMINAL_NAMES.system || 'System Terminal',
|
||||
detected: true
|
||||
});
|
||||
|
||||
// Add detected terminals
|
||||
if (detectedTools) {
|
||||
for (const tool of detectedTools.terminals) {
|
||||
if (tool.id !== 'system') {
|
||||
terminalOptions.push({
|
||||
value: tool.id as SupportedTerminal,
|
||||
label: tool.name,
|
||||
detected: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining terminals that weren't detected
|
||||
const detectedTerminalIds = new Set(detectedTools?.terminals.map(t => t.id) || []);
|
||||
detectedTerminalIds.add('system'); // Always consider system as detected
|
||||
for (const [id, name] of Object.entries(TERMINAL_NAMES)) {
|
||||
if (id !== 'custom' && !detectedTerminalIds.has(id)) {
|
||||
terminalOptions.push({
|
||||
value: id as SupportedTerminal,
|
||||
label: name,
|
||||
detected: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom option last
|
||||
terminalOptions.push({ value: 'custom', label: 'Custom...', detected: false });
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('devtools.title', 'Developer Tools')}
|
||||
description={t('devtools.description', 'Configure your preferred IDE and terminal for working with worktrees')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Detect Tools Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={detectTools}
|
||||
disabled={isDetecting}
|
||||
>
|
||||
{isDetecting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('devtools.detectAgain', 'Detect Again')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detectError && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
{detectError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IDE Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preferred-ide" className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
{t('devtools.ide.label', 'Preferred IDE')}
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.preferredIDE || 'vscode'}
|
||||
onValueChange={(value) => handleIDEChange(value as SupportedIDE)}
|
||||
>
|
||||
<SelectTrigger id="preferred-ide">
|
||||
<SelectValue placeholder={t('devtools.ide.placeholder', 'Select IDE...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ideOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{option.label}</span>
|
||||
{option.detected && (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('devtools.ide.description', 'Auto Claude will open worktrees in this editor')}
|
||||
</p>
|
||||
|
||||
{/* Custom IDE Path */}
|
||||
{settings.preferredIDE === 'custom' && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label htmlFor="custom-ide-path">
|
||||
{t('devtools.customPath', 'Custom path')}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="custom-ide-path"
|
||||
value={settings.customIDEPath || ''}
|
||||
onChange={(e) => handleCustomIDEPathChange(e.target.value)}
|
||||
placeholder="/path/to/your/ide"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI.selectDirectory();
|
||||
if (result) {
|
||||
handleCustomIDEPathChange(result);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preferred-terminal" className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
{t('devtools.terminal.label', 'Preferred Terminal')}
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.preferredTerminal || 'system'}
|
||||
onValueChange={(value) => handleTerminalChange(value as SupportedTerminal)}
|
||||
>
|
||||
<SelectTrigger id="preferred-terminal">
|
||||
<SelectValue placeholder={t('devtools.terminal.placeholder', 'Select terminal...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{terminalOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{option.label}</span>
|
||||
{option.detected && (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('devtools.terminal.description', 'Auto Claude will open terminal sessions here')}
|
||||
</p>
|
||||
|
||||
{/* Custom Terminal Path */}
|
||||
{settings.preferredTerminal === 'custom' && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label htmlFor="custom-terminal-path">
|
||||
{t('devtools.customPath', 'Custom path')}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="custom-terminal-path"
|
||||
value={settings.customTerminalPath || ''}
|
||||
onChange={(e) => handleCustomTerminalPathChange(e.target.value)}
|
||||
placeholder="/path/to/your/terminal"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI.selectDirectory();
|
||||
if (result) {
|
||||
handleCustomTerminalPathChange(result);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detection Summary */}
|
||||
{detectedTools && !isDetecting && (
|
||||
<div className="text-xs text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
<p className="font-medium mb-1">{t('devtools.detected', 'Detected on your system')}:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{detectedTools.ides.map((ide) => (
|
||||
<li key={ide.id}>{ide.name}</li>
|
||||
))}
|
||||
{detectedTools.terminals.filter(t => t.id !== 'system').map((term) => (
|
||||
<li key={term.id}>{term.name}</li>
|
||||
))}
|
||||
{detectedTools.ides.length === 0 && detectedTools.terminals.filter(t => t.id !== 'system').length === 0 && (
|
||||
<li>{t('devtools.noToolsDetected', 'No additional tools detected')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -94,8 +94,6 @@ export function TaskReview({
|
||||
{stagedSuccess && (
|
||||
<StagedSuccessMessage
|
||||
stagedSuccess={stagedSuccess}
|
||||
stagedProjectPath={stagedProjectPath}
|
||||
task={task}
|
||||
suggestedCommitMessage={suggestedCommitMessage}
|
||||
/>
|
||||
)}
|
||||
@@ -105,7 +103,6 @@ export function TaskReview({
|
||||
<LoadingMessage />
|
||||
) : worktreeStatus?.exists && !stagedSuccess ? (
|
||||
<WorkspaceStatus
|
||||
task={task}
|
||||
worktreeStatus={worktreeStatus}
|
||||
workspaceError={workspaceError}
|
||||
stageOnly={stageOnly}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for handling terminal creation with proper error handling and loading states.
|
||||
* Supports both inbuilt terminal and external system terminal.
|
||||
*/
|
||||
export function useTerminalHandler() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
|
||||
/**
|
||||
* Open an inbuilt terminal tab
|
||||
*/
|
||||
const openTerminal = async (id: string, cwd: string) => {
|
||||
setIsOpening(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.createTerminal({ id, cwd });
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to open terminal');
|
||||
console.error('[Terminal] Failed to open:', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(`Failed to open terminal: ${errorMsg}`);
|
||||
console.error('[Terminal] Exception:', err);
|
||||
} finally {
|
||||
setIsOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the path in the system's default external terminal application
|
||||
*/
|
||||
const openExternalTerminal = async (cwd: string) => {
|
||||
setIsOpening(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.openTerminal(cwd);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to open external terminal');
|
||||
console.error('[Terminal] Failed to open external:', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(`Failed to open external terminal: ${errorMsg}`);
|
||||
console.error('[Terminal] Exception:', err);
|
||||
} finally {
|
||||
setIsOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { openTerminal, openExternalTerminal, error, isOpening };
|
||||
}
|
||||
+2
-28
@@ -1,14 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { GitMerge, ExternalLink, Copy, Check, Sparkles } from 'lucide-react';
|
||||
import { GitMerge, Copy, Check, Sparkles } from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import type { Task } from '../../../../shared/types';
|
||||
import { useTerminalHandler } from '../hooks/useTerminalHandler';
|
||||
|
||||
interface StagedSuccessMessageProps {
|
||||
stagedSuccess: string;
|
||||
stagedProjectPath: string | undefined;
|
||||
task: Task;
|
||||
suggestedCommitMessage?: string;
|
||||
}
|
||||
|
||||
@@ -17,13 +13,10 @@ interface StagedSuccessMessageProps {
|
||||
*/
|
||||
export function StagedSuccessMessage({
|
||||
stagedSuccess,
|
||||
stagedProjectPath,
|
||||
task,
|
||||
suggestedCommitMessage
|
||||
}: StagedSuccessMessageProps) {
|
||||
const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || '');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { openTerminal, error: terminalError, isOpening } = useTerminalHandler();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!commitMessage) return;
|
||||
@@ -86,7 +79,7 @@ export function StagedSuccessMessage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-background/50 rounded-lg p-3 mb-3">
|
||||
<div className="bg-background/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Next steps:</p>
|
||||
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Open your project in your IDE or terminal</li>
|
||||
@@ -94,25 +87,6 @@ export function StagedSuccessMessage({
|
||||
<li>Commit when ready: <code className="bg-background px-1 rounded">git commit -m "your message"</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
{stagedProjectPath && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openTerminal(`project-${task.id}`, stagedProjectPath)}
|
||||
className="w-full"
|
||||
disabled={isOpening}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{isOpening ? 'Opening Terminal...' : 'Open Project in Terminal'}
|
||||
</Button>
|
||||
{terminalError && (
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
{terminalError}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Plus,
|
||||
Minus,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
FolderX,
|
||||
Loader2,
|
||||
@@ -12,17 +11,16 @@ import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
GitCommit,
|
||||
Code,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { Task, WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
|
||||
import { useTerminalHandler } from '../hooks/useTerminalHandler';
|
||||
import { TerminalDropdown } from './TerminalDropdown';
|
||||
import type { WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo, SupportedIDE, SupportedTerminal } from '../../../../shared/types';
|
||||
import { useSettingsStore } from '../../../stores/settings-store';
|
||||
|
||||
interface WorkspaceStatusProps {
|
||||
task: Task;
|
||||
worktreeStatus: WorktreeStatus;
|
||||
workspaceError: string | null;
|
||||
stageOnly: boolean;
|
||||
@@ -44,8 +42,41 @@ interface WorkspaceStatusProps {
|
||||
/**
|
||||
* Displays the workspace status including change summary, merge preview, and action buttons
|
||||
*/
|
||||
// IDE display names for button labels (short names for buttons)
|
||||
const IDE_LABELS: Partial<Record<SupportedIDE, string>> = {
|
||||
vscode: 'VS Code',
|
||||
cursor: 'Cursor',
|
||||
windsurf: 'Windsurf',
|
||||
zed: 'Zed',
|
||||
sublime: 'Sublime',
|
||||
webstorm: 'WebStorm',
|
||||
intellij: 'IntelliJ',
|
||||
pycharm: 'PyCharm',
|
||||
xcode: 'Xcode',
|
||||
vim: 'Vim',
|
||||
neovim: 'Neovim',
|
||||
emacs: 'Emacs',
|
||||
custom: 'IDE'
|
||||
};
|
||||
|
||||
// Terminal display names for button labels (short names for buttons)
|
||||
const TERMINAL_LABELS: Partial<Record<SupportedTerminal, string>> = {
|
||||
system: 'Terminal',
|
||||
terminal: 'Terminal',
|
||||
iterm2: 'iTerm',
|
||||
warp: 'Warp',
|
||||
ghostty: 'Ghostty',
|
||||
alacritty: 'Alacritty',
|
||||
kitty: 'Kitty',
|
||||
wezterm: 'WezTerm',
|
||||
hyper: 'Hyper',
|
||||
windowsterminal: 'Terminal',
|
||||
gnometerminal: 'Terminal',
|
||||
konsole: 'Konsole',
|
||||
custom: 'Terminal'
|
||||
};
|
||||
|
||||
export function WorkspaceStatus({
|
||||
task,
|
||||
worktreeStatus,
|
||||
workspaceError,
|
||||
stageOnly,
|
||||
@@ -63,7 +94,36 @@ export function WorkspaceStatus({
|
||||
onSwitchToTerminals,
|
||||
onOpenInbuiltTerminal
|
||||
}: WorkspaceStatusProps) {
|
||||
const { openTerminal, openExternalTerminal, error: terminalError, isOpening } = useTerminalHandler();
|
||||
const { settings } = useSettingsStore();
|
||||
const preferredIDE = settings.preferredIDE || 'vscode';
|
||||
const preferredTerminal = settings.preferredTerminal || 'system';
|
||||
|
||||
const handleOpenInIDE = async () => {
|
||||
if (!worktreeStatus.worktreePath) return;
|
||||
try {
|
||||
await window.electronAPI.worktreeOpenInIDE(
|
||||
worktreeStatus.worktreePath,
|
||||
preferredIDE,
|
||||
settings.customIDEPath
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to open in IDE:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInTerminal = async () => {
|
||||
if (!worktreeStatus.worktreePath) return;
|
||||
try {
|
||||
await window.electronAPI.worktreeOpenInTerminal(
|
||||
worktreeStatus.worktreePath,
|
||||
preferredTerminal,
|
||||
settings.customTerminalPath
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to open in terminal:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts;
|
||||
const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges;
|
||||
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
|
||||
@@ -94,29 +154,15 @@ export function WorkspaceStatus({
|
||||
<GitBranch className="h-4 w-4 text-purple-400" />
|
||||
Build Ready for Review
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onShowDiffDialog(true)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
{worktreeStatus.worktreePath && (
|
||||
<TerminalDropdown
|
||||
onOpenInbuilt={() => {
|
||||
if (onOpenInbuiltTerminal) {
|
||||
onOpenInbuiltTerminal(`open-${task.id}`, worktreeStatus.worktreePath!);
|
||||
}
|
||||
}}
|
||||
onOpenExternal={() => openExternalTerminal(worktreeStatus.worktreePath!)}
|
||||
disabled={isOpening}
|
||||
className="h-7 px-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onShowDiffDialog(true)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Compact stats row */}
|
||||
@@ -155,10 +201,27 @@ export function WorkspaceStatus({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal error display */}
|
||||
{terminalError && (
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
{terminalError}
|
||||
{/* Open in IDE/Terminal buttons */}
|
||||
{worktreeStatus.worktreePath && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenInIDE}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Code className="h-3.5 w-3.5 mr-1" />
|
||||
Open in {IDE_LABELS[preferredIDE]}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenInTerminal}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5 mr-1" />
|
||||
Open in {TERMINAL_LABELS[preferredTerminal]}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -182,24 +245,8 @@ export function WorkspaceStatus({
|
||||
{uncommittedCount} uncommitted {uncommittedCount === 1 ? 'change' : 'changes'} in main project
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Commit or stash them before staging to avoid conflicts.
|
||||
Commit or stash them in your terminal before staging to avoid conflicts.
|
||||
</p>
|
||||
<TerminalDropdown
|
||||
onOpenInbuilt={() => {
|
||||
const mainProjectPath = worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || '';
|
||||
if (mainProjectPath && onOpenInbuiltTerminal) {
|
||||
onOpenInbuiltTerminal(`stash-${task.id}`, mainProjectPath);
|
||||
}
|
||||
}}
|
||||
onOpenExternal={() => {
|
||||
const mainProjectPath = worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || '';
|
||||
if (mainProjectPath) {
|
||||
openExternalTerminal(mainProjectPath);
|
||||
}
|
||||
}}
|
||||
disabled={isOpening}
|
||||
className="text-xs h-6 mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface TerminalHeaderProps {
|
||||
onTaskSelect: (taskId: string) => void;
|
||||
onClearTask: () => void;
|
||||
onNewTaskClick?: () => void;
|
||||
terminalCount?: number;
|
||||
}
|
||||
|
||||
export function TerminalHeader({
|
||||
@@ -35,6 +36,7 @@ export function TerminalHeader({
|
||||
onTaskSelect,
|
||||
onClearTask,
|
||||
onNewTaskClick,
|
||||
terminalCount = 1,
|
||||
}: TerminalHeaderProps) {
|
||||
const backlogTasks = tasks.filter((t) => t.status === 'backlog');
|
||||
|
||||
@@ -48,6 +50,7 @@ export function TerminalHeader({
|
||||
title={title}
|
||||
associatedTask={associatedTask}
|
||||
onTitleChange={onTitleChange}
|
||||
terminalCount={terminalCount}
|
||||
/>
|
||||
</div>
|
||||
{isClaudeMode && (
|
||||
|
||||
@@ -6,16 +6,20 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../ui/tooltip';
|
||||
import { getTitleMaxWidthClass } from './types';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface TerminalTitleProps {
|
||||
title: string;
|
||||
associatedTask?: Task;
|
||||
onTitleChange: (newTitle: string) => void;
|
||||
terminalCount?: number;
|
||||
}
|
||||
|
||||
export function TerminalTitle({ title, associatedTask, onTitleChange }: TerminalTitleProps) {
|
||||
export function TerminalTitle({ title, associatedTask, onTitleChange, terminalCount = 1 }: TerminalTitleProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState('');
|
||||
const maxWidthClass = getTitleMaxWidthClass(terminalCount);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
@@ -60,7 +64,7 @@ export function TerminalTitle({ title, associatedTask, onTitleChange }: Terminal
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs font-medium text-foreground bg-transparent border border-primary/50 rounded px-1 py-0.5 outline-none focus:border-primary max-w-32"
|
||||
className={cn("text-xs font-medium text-foreground bg-transparent border border-primary/50 rounded px-1 py-0.5 outline-none focus:border-primary", maxWidthClass)}
|
||||
style={{ width: `${Math.max(editedTitle.length * 6 + 16, 60)}px` }}
|
||||
/>
|
||||
);
|
||||
@@ -72,7 +76,7 @@ export function TerminalTitle({ title, associatedTask, onTitleChange }: Terminal
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="text-xs font-medium text-foreground truncate max-w-32 cursor-text hover:text-primary/80 transition-colors"
|
||||
className={cn("text-xs font-medium text-foreground truncate cursor-text hover:text-primary/80 transition-colors", maxWidthClass)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEdit();
|
||||
@@ -95,7 +99,7 @@ export function TerminalTitle({ title, associatedTask, onTitleChange }: Terminal
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="text-xs font-medium text-foreground truncate max-w-32 cursor-text hover:text-primary/80 transition-colors"
|
||||
className={cn("text-xs font-medium text-foreground truncate cursor-text hover:text-primary/80 transition-colors", maxWidthClass)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEdit();
|
||||
|
||||
@@ -11,6 +11,19 @@ export interface TerminalProps {
|
||||
onActivate: () => void;
|
||||
tasks?: Task[];
|
||||
onNewTaskClick?: () => void;
|
||||
terminalCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the responsive max-width class for terminal title based on terminal count.
|
||||
* More terminals = narrower title to fit all elements.
|
||||
*/
|
||||
export function getTitleMaxWidthClass(terminalCount: number): string {
|
||||
if (terminalCount <= 2) return 'max-w-64'; // 256px - large
|
||||
if (terminalCount <= 4) return 'max-w-48'; // 192px - medium
|
||||
if (terminalCount <= 6) return 'max-w-40'; // 160px - default
|
||||
if (terminalCount <= 9) return 'max-w-32'; // 128px - compact
|
||||
return 'max-w-24'; // 96px - very compact for 10-12 terminals
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<TerminalStatus, string> = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTerminalStore } from '../../stores/terminal-store';
|
||||
import { terminalBufferManager } from '../../lib/terminal-buffer-manager';
|
||||
|
||||
@@ -17,41 +17,66 @@ export function useTerminalEvents({
|
||||
onTitleChange,
|
||||
onClaudeSession,
|
||||
}: UseTerminalEventsOptions) {
|
||||
// Use refs to always have the latest callbacks without re-registering listeners
|
||||
// This prevents duplicate listener registration when callbacks change identity
|
||||
const onOutputRef = useRef(onOutput);
|
||||
const onExitRef = useRef(onExit);
|
||||
const onTitleChangeRef = useRef(onTitleChange);
|
||||
const onClaudeSessionRef = useRef(onClaudeSession);
|
||||
|
||||
// Keep refs updated with latest callbacks
|
||||
useEffect(() => {
|
||||
onOutputRef.current = onOutput;
|
||||
}, [onOutput]);
|
||||
|
||||
useEffect(() => {
|
||||
onExitRef.current = onExit;
|
||||
}, [onExit]);
|
||||
|
||||
useEffect(() => {
|
||||
onTitleChangeRef.current = onTitleChange;
|
||||
}, [onTitleChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onClaudeSessionRef.current = onClaudeSession;
|
||||
}, [onClaudeSession]);
|
||||
|
||||
// Handle terminal output from main process
|
||||
// Only depends on terminalId (stable) to prevent listener re-registration
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onTerminalOutput((id, data) => {
|
||||
if (id === terminalId) {
|
||||
terminalBufferManager.append(terminalId, data);
|
||||
onOutput?.(data);
|
||||
onOutputRef.current?.(data);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [terminalId, onOutput]);
|
||||
}, [terminalId]);
|
||||
|
||||
// Handle terminal exit
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onTerminalExit((id, exitCode) => {
|
||||
if (id === terminalId) {
|
||||
useTerminalStore.getState().setTerminalStatus(terminalId, 'exited');
|
||||
onExit?.(exitCode);
|
||||
onExitRef.current?.(exitCode);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [terminalId, onExit]);
|
||||
}, [terminalId]);
|
||||
|
||||
// Handle terminal title change
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onTerminalTitleChange((id, title) => {
|
||||
if (id === terminalId) {
|
||||
useTerminalStore.getState().updateTerminal(terminalId, { title });
|
||||
onTitleChange?.(title);
|
||||
onTitleChangeRef.current?.(title);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [terminalId, onTitleChange]);
|
||||
}, [terminalId]);
|
||||
|
||||
// Handle Claude session ID capture
|
||||
useEffect(() => {
|
||||
@@ -59,10 +84,10 @@ export function useTerminalEvents({
|
||||
if (id === terminalId) {
|
||||
useTerminalStore.getState().setClaudeSessionId(terminalId, sessionId);
|
||||
console.warn('[Terminal] Captured Claude session ID:', sessionId);
|
||||
onClaudeSession?.(sessionId);
|
||||
onClaudeSessionRef.current?.(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [terminalId, onClaudeSession]);
|
||||
}, [terminalId]);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,25 @@ export function useXterm({ terminalId, onCommandEnter, onResize }: UseXtermOptio
|
||||
|
||||
xterm.open(terminalRef.current);
|
||||
|
||||
// Allow certain key combinations to bubble up to window-level handlers
|
||||
// This enables global shortcuts like Cmd/Ctrl+1-9 for project switching
|
||||
xterm.attachCustomKeyEventHandler((event) => {
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
|
||||
// Let Cmd/Ctrl + number keys pass through for project tab switching
|
||||
if (isMod && event.key >= '1' && event.key <= '9') {
|
||||
return false; // Don't handle in xterm, let it bubble up
|
||||
}
|
||||
|
||||
// Let Cmd/Ctrl + Tab pass through for tab navigation
|
||||
if (isMod && event.key === 'Tab') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle all other keys in xterm
|
||||
return true;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 50);
|
||||
|
||||
@@ -59,7 +59,7 @@ const DialogContent = React.forwardRef<
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-lg p-1',
|
||||
'absolute right-4 top-4 rounded-lg p-1 z-10',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
|
||||
|
||||
@@ -170,7 +170,23 @@ const browserMockAPI: ElectronAPI = {
|
||||
onAnalyzePreviewProgress: () => () => {},
|
||||
onAnalyzePreviewComplete: () => () => {},
|
||||
onAnalyzePreviewError: () => () => {}
|
||||
}
|
||||
},
|
||||
|
||||
// Debug Operations
|
||||
getDebugInfo: async () => ({
|
||||
systemInfo: {
|
||||
appVersion: '0.0.0-browser-mock',
|
||||
platform: 'browser',
|
||||
isPackaged: 'false'
|
||||
},
|
||||
recentErrors: [],
|
||||
logsPath: '/mock/logs',
|
||||
debugReport: '[Browser Mock] Debug report not available in browser mode'
|
||||
}),
|
||||
openLogsFolder: async () => ({ success: false, error: 'Not available in browser mode' }),
|
||||
copyDebugInfo: async () => ({ success: false, error: 'Not available in browser mode' }),
|
||||
getRecentErrors: async () => [],
|
||||
listLogFiles: async () => []
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,5 +67,27 @@ export const workspaceMock = {
|
||||
data: {
|
||||
worktrees: []
|
||||
}
|
||||
}),
|
||||
|
||||
worktreeOpenInIDE: async () => ({
|
||||
success: true,
|
||||
data: { opened: true }
|
||||
}),
|
||||
|
||||
worktreeOpenInTerminal: async () => ({
|
||||
success: true,
|
||||
data: { opened: true }
|
||||
}),
|
||||
|
||||
worktreeDetectTools: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
ides: [
|
||||
{ id: 'vscode', name: 'Visual Studio Code', path: '/Applications/Visual Studio Code.app', installed: true }
|
||||
],
|
||||
terminals: [
|
||||
{ id: 'system', name: 'System Terminal', path: '', installed: true }
|
||||
]
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface DownloadProgress {
|
||||
modelName: string;
|
||||
status: 'starting' | 'downloading' | 'completed' | 'failed';
|
||||
percentage: number;
|
||||
speed?: string;
|
||||
timeRemaining?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface DownloadState {
|
||||
// Map of modelName -> progress
|
||||
downloads: Record<string, DownloadProgress>;
|
||||
|
||||
// Actions
|
||||
startDownload: (modelName: string) => void;
|
||||
updateProgress: (modelName: string, progress: Partial<DownloadProgress>) => void;
|
||||
completeDownload: (modelName: string) => void;
|
||||
failDownload: (modelName: string, error: string) => void;
|
||||
clearDownload: (modelName: string) => void;
|
||||
|
||||
// Selectors
|
||||
hasActiveDownloads: () => boolean;
|
||||
getActiveDownloads: () => DownloadProgress[];
|
||||
}
|
||||
|
||||
// Progress tracking state for speed calculation
|
||||
// Defined before store so cleanup can be called from store actions
|
||||
const progressTracker: Record<string, { lastCompleted: number; lastUpdate: number }> = {};
|
||||
|
||||
/**
|
||||
* Clean up progress tracker entry to prevent memory leaks.
|
||||
* Called when downloads are cleared.
|
||||
*/
|
||||
function cleanupProgressTracker(modelName: string): void {
|
||||
delete progressTracker[modelName];
|
||||
}
|
||||
|
||||
export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
downloads: {},
|
||||
|
||||
startDownload: (modelName: string) =>
|
||||
set((state) => ({
|
||||
downloads: {
|
||||
...state.downloads,
|
||||
[modelName]: {
|
||||
modelName,
|
||||
status: 'starting',
|
||||
percentage: 0,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
updateProgress: (modelName: string, progress: Partial<DownloadProgress>) =>
|
||||
set((state) => {
|
||||
const existing = state.downloads[modelName];
|
||||
if (!existing) return state;
|
||||
|
||||
return {
|
||||
downloads: {
|
||||
...state.downloads,
|
||||
[modelName]: {
|
||||
...existing,
|
||||
...progress,
|
||||
status: progress.percentage !== undefined && progress.percentage > 0
|
||||
? 'downloading'
|
||||
: existing.status,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
completeDownload: (modelName: string) =>
|
||||
set((state) => {
|
||||
const existing = state.downloads[modelName];
|
||||
if (!existing) return state;
|
||||
|
||||
// Clean up progress tracker when download completes
|
||||
cleanupProgressTracker(modelName);
|
||||
|
||||
return {
|
||||
downloads: {
|
||||
...state.downloads,
|
||||
[modelName]: {
|
||||
...existing,
|
||||
status: 'completed',
|
||||
percentage: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
failDownload: (modelName: string, error: string) =>
|
||||
set((state) => {
|
||||
const existing = state.downloads[modelName];
|
||||
if (!existing) return state;
|
||||
|
||||
// Clean up progress tracker when download fails
|
||||
cleanupProgressTracker(modelName);
|
||||
|
||||
return {
|
||||
downloads: {
|
||||
...state.downloads,
|
||||
[modelName]: {
|
||||
...existing,
|
||||
status: 'failed',
|
||||
error,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
clearDownload: (modelName: string) =>
|
||||
set((state) => {
|
||||
// Clean up progress tracker to prevent memory leaks
|
||||
cleanupProgressTracker(modelName);
|
||||
|
||||
const { [modelName]: _, ...rest } = state.downloads;
|
||||
return { downloads: rest };
|
||||
}),
|
||||
|
||||
hasActiveDownloads: () => {
|
||||
const downloads = get().downloads;
|
||||
return Object.values(downloads).some(
|
||||
(d) => d.status === 'starting' || d.status === 'downloading'
|
||||
);
|
||||
},
|
||||
|
||||
getActiveDownloads: () => {
|
||||
const downloads = get().downloads;
|
||||
return Object.values(downloads).filter(
|
||||
(d) => d.status === 'starting' || d.status === 'downloading'
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Subscribe to download progress events from the main process.
|
||||
* Call this once when the app starts.
|
||||
*/
|
||||
export function initDownloadProgressListener(): () => void {
|
||||
const handleProgress = (data: {
|
||||
modelName: string;
|
||||
status: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}) => {
|
||||
const store = useDownloadStore.getState();
|
||||
const now = Date.now();
|
||||
|
||||
// Initialize tracking for this model if needed
|
||||
if (!progressTracker[data.modelName]) {
|
||||
progressTracker[data.modelName] = {
|
||||
lastCompleted: data.completed,
|
||||
lastUpdate: now,
|
||||
};
|
||||
}
|
||||
|
||||
const prevData = progressTracker[data.modelName];
|
||||
const timeDelta = now - prevData.lastUpdate;
|
||||
const bytesDelta = data.completed - prevData.lastCompleted;
|
||||
|
||||
// Calculate speed only if we have meaningful time delta (> 100ms)
|
||||
let speedStr = '';
|
||||
let timeStr = '';
|
||||
|
||||
if (timeDelta > 100 && bytesDelta > 0) {
|
||||
const speed = (bytesDelta / timeDelta) * 1000; // bytes per second
|
||||
const remaining = data.total - data.completed;
|
||||
const timeRemaining = speed > 0 ? Math.ceil(remaining / speed) : 0;
|
||||
|
||||
// Format speed (MB/s or KB/s)
|
||||
if (speed > 1024 * 1024) {
|
||||
speedStr = `${(speed / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
} else if (speed > 1024) {
|
||||
speedStr = `${(speed / 1024).toFixed(1)} KB/s`;
|
||||
} else if (speed > 0) {
|
||||
speedStr = `${Math.round(speed)} B/s`;
|
||||
}
|
||||
|
||||
// Format time remaining
|
||||
if (timeRemaining > 3600) {
|
||||
timeStr = `${Math.ceil(timeRemaining / 3600)}h remaining`;
|
||||
} else if (timeRemaining > 60) {
|
||||
timeStr = `${Math.ceil(timeRemaining / 60)}m remaining`;
|
||||
} else if (timeRemaining > 0) {
|
||||
timeStr = `${Math.ceil(timeRemaining)}s remaining`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tracking
|
||||
progressTracker[data.modelName] = {
|
||||
lastCompleted: data.completed,
|
||||
lastUpdate: now,
|
||||
};
|
||||
|
||||
store.updateProgress(data.modelName, {
|
||||
percentage: data.percentage,
|
||||
speed: speedStr || undefined,
|
||||
timeRemaining: timeStr || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Register the progress listener
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
if (window.electronAPI?.onDownloadProgress) {
|
||||
unsubscribe = window.electronAPI.onDownloadProgress(handleProgress);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -90,6 +90,11 @@
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(71, 159, 250, 0.4); }
|
||||
50% { opacity: 0.95; box-shadow: 0 0 0 4px rgba(71, 159, 250, 0.1); }
|
||||
}
|
||||
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation utility classes (outside @theme for Tailwind v4 compatibility) */
|
||||
@@ -97,6 +102,10 @@
|
||||
animation: pulse-subtle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-indeterminate {
|
||||
animation: indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* CSS variables for light mode (secondary consideration per design system) */
|
||||
:root {
|
||||
/* Light Mode - Warm off-white tones */
|
||||
|
||||
@@ -67,7 +67,9 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
},
|
||||
// Graphiti MCP server for agent-accessible knowledge graph (enabled by default)
|
||||
graphitiMcpEnabled: true,
|
||||
graphitiMcpUrl: 'http://localhost:8000/mcp/'
|
||||
graphitiMcpUrl: 'http://localhost:8000/mcp/',
|
||||
// Include CLAUDE.md instructions in agent context (enabled by default)
|
||||
useClaudeMd: true
|
||||
};
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -35,6 +35,9 @@ export const IPC_CHANNELS = {
|
||||
TASK_WORKTREE_MERGE: 'task:worktreeMerge',
|
||||
TASK_WORKTREE_MERGE_PREVIEW: 'task:worktreeMergePreview', // Preview merge conflicts before merging
|
||||
TASK_WORKTREE_DISCARD: 'task:worktreeDiscard',
|
||||
TASK_WORKTREE_OPEN_IN_IDE: 'task:worktreeOpenInIDE',
|
||||
TASK_WORKTREE_OPEN_IN_TERMINAL: 'task:worktreeOpenInTerminal',
|
||||
TASK_WORKTREE_DETECT_TOOLS: 'task:worktreeDetectTools', // Detect installed IDEs/terminals
|
||||
TASK_LIST_WORKTREES: 'task:listWorktrees',
|
||||
TASK_ARCHIVE: 'task:archive',
|
||||
TASK_UNARCHIVE: 'task:unarchive',
|
||||
@@ -363,5 +366,12 @@ export const IPC_CHANNELS = {
|
||||
RELEASE_GET_VERSIONS: 'release:getVersions',
|
||||
|
||||
// Release events (main -> renderer)
|
||||
RELEASE_PROGRESS: 'release:progress'
|
||||
RELEASE_PROGRESS: 'release:progress',
|
||||
|
||||
// Debug operations
|
||||
DEBUG_GET_INFO: 'debug:getInfo',
|
||||
DEBUG_OPEN_LOGS_FOLDER: 'debug:openLogsFolder',
|
||||
DEBUG_COPY_DEBUG_INFO: 'debug:copyDebugInfo',
|
||||
DEBUG_GET_RECENT_ERRORS: 'debug:getRecentErrors',
|
||||
DEBUG_LIST_LOG_FILES: 'debug:listLogFiles'
|
||||
} as const;
|
||||
|
||||
@@ -98,5 +98,18 @@
|
||||
"readyForFollowup": "Ready for Follow-up",
|
||||
"readyToMerge": "Ready to Merge",
|
||||
"pendingPost": "Pending Post"
|
||||
},
|
||||
"downloads": {
|
||||
"toggleExpand": "Toggle download details",
|
||||
"downloading": "Downloading {{count}} model",
|
||||
"downloading_plural": "Downloading {{count}} models",
|
||||
"complete": "{{count}} download complete",
|
||||
"complete_plural": "{{count}} downloads complete",
|
||||
"failed": "{{count}} download failed",
|
||||
"failed_plural": "{{count}} downloads failed",
|
||||
"clearAll": "Clear all completed downloads",
|
||||
"done": "Done",
|
||||
"failedLabel": "Failed",
|
||||
"starting": "Starting..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"githubIssues": "GitHub Issues",
|
||||
"githubPRs": "GitHub PRs",
|
||||
"gitlabIssues": "GitLab Issues",
|
||||
"worktrees": "Worktrees"
|
||||
"worktrees": "Worktrees",
|
||||
"agentTools": "MCP Overview"
|
||||
},
|
||||
"actions": {
|
||||
"settings": "Settings",
|
||||
|
||||
@@ -64,7 +64,30 @@
|
||||
"steps": {
|
||||
"welcome": "Welcome",
|
||||
"auth": "Auth",
|
||||
"devtools": "Dev Tools",
|
||||
"memory": "Memory",
|
||||
"done": "Done"
|
||||
},
|
||||
"devtools": {
|
||||
"title": "Developer Tools",
|
||||
"description": "Choose your preferred IDE and terminal for working with Auto Claude worktrees",
|
||||
"detecting": "Detecting installed tools...",
|
||||
"detectAgain": "Detect Again",
|
||||
"whyConfigure": "Why configure these?",
|
||||
"whyConfigureDescription": "When Auto Claude builds features in isolated worktrees, you can open them directly in your preferred IDE or terminal to test and review changes.",
|
||||
"ide": {
|
||||
"label": "Preferred IDE",
|
||||
"description": "Auto Claude will open worktrees in this editor",
|
||||
"customPath": "Custom IDE Path"
|
||||
},
|
||||
"terminal": {
|
||||
"label": "Preferred Terminal",
|
||||
"description": "Auto Claude will open terminal sessions here",
|
||||
"customPath": "Custom Terminal Path"
|
||||
},
|
||||
"detectedSummary": "Detected on your system:",
|
||||
"noToolsDetected": "No additional tools detected (VS Code and system terminal will be used)",
|
||||
"custom": "Custom...",
|
||||
"saveAndContinue": "Save & Continue"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"title": "Language",
|
||||
"description": "Choose your preferred language"
|
||||
},
|
||||
"devtools": {
|
||||
"title": "Developer Tools",
|
||||
"description": "IDE and terminal preferences"
|
||||
},
|
||||
"agent": {
|
||||
"title": "Agent Settings",
|
||||
"description": "Default model and framework"
|
||||
@@ -36,6 +40,10 @@
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Alert preferences"
|
||||
},
|
||||
"debug": {
|
||||
"title": "Debug & Logs",
|
||||
"description": "Troubleshooting tools"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
@@ -104,6 +112,30 @@
|
||||
"colorTheme": "Color Theme",
|
||||
"colorThemeDescription": "Choose your preferred color palette"
|
||||
},
|
||||
"devtools": {
|
||||
"title": "Developer Tools",
|
||||
"description": "Configure your preferred IDE and terminal for working with worktrees",
|
||||
"detecting": "Detecting installed tools...",
|
||||
"detectAgain": "Detect Again",
|
||||
"ide": {
|
||||
"label": "Preferred IDE",
|
||||
"description": "Auto Claude will open worktrees in this editor",
|
||||
"placeholder": "Select IDE...",
|
||||
"customPath": "Custom IDE Path",
|
||||
"customPathPlaceholder": "/path/to/your/ide"
|
||||
},
|
||||
"terminal": {
|
||||
"label": "Preferred Terminal",
|
||||
"description": "Auto Claude will open terminal sessions here",
|
||||
"placeholder": "Select terminal...",
|
||||
"customPath": "Custom Terminal Path",
|
||||
"customPathPlaceholder": "/path/to/your/terminal"
|
||||
},
|
||||
"detected": "Detected",
|
||||
"notInstalled": "Not installed",
|
||||
"detectedSummary": "Detected on your system:",
|
||||
"noToolsDetected": "No additional tools detected (VS Code and system terminal will be used)"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Updates",
|
||||
"description": "Manage Auto Claude updates",
|
||||
@@ -147,7 +179,9 @@
|
||||
"projectSections": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "Auto-Build and agent config"
|
||||
"description": "Auto-Build and agent config",
|
||||
"useClaudeMd": "Use CLAUDE.md",
|
||||
"useClaudeMdDescription": "Include CLAUDE.md instructions in agent context"
|
||||
},
|
||||
"claude": {
|
||||
"title": "Claude Auth",
|
||||
@@ -254,5 +288,19 @@
|
||||
"apiKeysInfo": "Keys set here are used as defaults. Individual projects can override these in their settings.",
|
||||
"openaiKey": "OpenAI API Key",
|
||||
"openaiKeyDescription": "Required for Graphiti memory backend (embeddings)"
|
||||
},
|
||||
"debug": {
|
||||
"title": "Debug & Logs",
|
||||
"description": "Access logs and debug information for troubleshooting",
|
||||
"openLogsFolder": "Open Logs Folder",
|
||||
"copyDebugInfo": "Copy Debug Info",
|
||||
"copied": "Copied!",
|
||||
"loadInfo": "Load Debug Info",
|
||||
"systemInfo": "System Information",
|
||||
"logsLocation": "Logs Location",
|
||||
"recentErrors": "Recent Errors",
|
||||
"noRecentErrors": "No recent errors",
|
||||
"helpTitle": "Reporting Issues",
|
||||
"helpText": "When reporting bugs, click \"Copy Debug Info\" to get system information and recent errors that help us diagnose the issue."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,5 +98,18 @@
|
||||
"readyForFollowup": "Prêt pour suivi",
|
||||
"readyToMerge": "Prêt à fusionner",
|
||||
"pendingPost": "En attente de publication"
|
||||
},
|
||||
"downloads": {
|
||||
"toggleExpand": "Afficher/masquer les détails",
|
||||
"downloading": "Téléchargement de {{count}} modèle",
|
||||
"downloading_plural": "Téléchargement de {{count}} modèles",
|
||||
"complete": "{{count}} téléchargement terminé",
|
||||
"complete_plural": "{{count}} téléchargements terminés",
|
||||
"failed": "{{count}} téléchargement échoué",
|
||||
"failed_plural": "{{count}} téléchargements échoués",
|
||||
"clearAll": "Effacer tous les téléchargements terminés",
|
||||
"done": "Terminé",
|
||||
"failedLabel": "Échoué",
|
||||
"starting": "Démarrage..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"githubIssues": "Issues GitHub",
|
||||
"githubPRs": "PRs GitHub",
|
||||
"gitlabIssues": "Issues GitLab",
|
||||
"worktrees": "Worktrees"
|
||||
"worktrees": "Worktrees",
|
||||
"agentTools": "Aperçu MCP"
|
||||
},
|
||||
"actions": {
|
||||
"settings": "Paramètres",
|
||||
|
||||
@@ -64,7 +64,30 @@
|
||||
"steps": {
|
||||
"welcome": "Bienvenue",
|
||||
"auth": "Auth",
|
||||
"devtools": "Outils dev",
|
||||
"memory": "Mémoire",
|
||||
"done": "Terminé"
|
||||
},
|
||||
"devtools": {
|
||||
"title": "Outils de développement",
|
||||
"description": "Choisissez votre IDE et terminal préférés pour travailler avec les worktrees Auto Claude",
|
||||
"detecting": "Détection des outils installés...",
|
||||
"detectAgain": "Détecter à nouveau",
|
||||
"whyConfigure": "Pourquoi configurer ceci ?",
|
||||
"whyConfigureDescription": "Quand Auto Claude construit des fonctionnalités dans des worktrees isolés, vous pouvez les ouvrir directement dans votre IDE ou terminal préféré pour tester et réviser les changements.",
|
||||
"ide": {
|
||||
"label": "IDE préféré",
|
||||
"description": "Auto Claude ouvrira les worktrees dans cet éditeur",
|
||||
"customPath": "Chemin IDE personnalisé"
|
||||
},
|
||||
"terminal": {
|
||||
"label": "Terminal préféré",
|
||||
"description": "Auto Claude ouvrira les sessions terminal ici",
|
||||
"customPath": "Chemin terminal personnalisé"
|
||||
},
|
||||
"detectedSummary": "Détecté sur votre système :",
|
||||
"noToolsDetected": "Aucun outil supplémentaire détecté (VS Code et le terminal système seront utilisés)",
|
||||
"custom": "Personnalisé...",
|
||||
"saveAndContinue": "Enregistrer et continuer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"title": "Langue",
|
||||
"description": "Choisissez votre langue préférée"
|
||||
},
|
||||
"devtools": {
|
||||
"title": "Outils de développement",
|
||||
"description": "Préférences IDE et terminal"
|
||||
},
|
||||
"agent": {
|
||||
"title": "Paramètres de l'agent",
|
||||
"description": "Modèle par défaut et framework"
|
||||
@@ -36,6 +40,10 @@
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Préférences d'alertes"
|
||||
},
|
||||
"debug": {
|
||||
"title": "Debug & Logs",
|
||||
"description": "Outils de dépannage"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
@@ -104,6 +112,30 @@
|
||||
"colorTheme": "Thème de couleur",
|
||||
"colorThemeDescription": "Choisissez votre palette de couleurs préférée"
|
||||
},
|
||||
"devtools": {
|
||||
"title": "Outils de développement",
|
||||
"description": "Configurez votre IDE et terminal préférés pour travailler avec les worktrees",
|
||||
"detecting": "Détection des outils installés...",
|
||||
"detectAgain": "Détecter à nouveau",
|
||||
"ide": {
|
||||
"label": "IDE préféré",
|
||||
"description": "Auto Claude ouvrira les worktrees dans cet éditeur",
|
||||
"placeholder": "Sélectionner un IDE...",
|
||||
"customPath": "Chemin IDE personnalisé",
|
||||
"customPathPlaceholder": "/chemin/vers/votre/ide"
|
||||
},
|
||||
"terminal": {
|
||||
"label": "Terminal préféré",
|
||||
"description": "Auto Claude ouvrira les sessions terminal ici",
|
||||
"placeholder": "Sélectionner un terminal...",
|
||||
"customPath": "Chemin terminal personnalisé",
|
||||
"customPathPlaceholder": "/chemin/vers/votre/terminal"
|
||||
},
|
||||
"detected": "Détecté",
|
||||
"notInstalled": "Non installé",
|
||||
"detectedSummary": "Détecté sur votre système :",
|
||||
"noToolsDetected": "Aucun outil supplémentaire détecté (VS Code et le terminal système seront utilisés)"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Mises à jour",
|
||||
"description": "Gérer les mises à jour de Auto Claude",
|
||||
@@ -147,7 +179,9 @@
|
||||
"projectSections": {
|
||||
"general": {
|
||||
"title": "Général",
|
||||
"description": "Auto-Build et configuration de l'agent"
|
||||
"description": "Auto-Build et configuration de l'agent",
|
||||
"useClaudeMd": "Utiliser CLAUDE.md",
|
||||
"useClaudeMdDescription": "Inclure les instructions CLAUDE.md dans le contexte de l'agent"
|
||||
},
|
||||
"claude": {
|
||||
"title": "Auth Claude",
|
||||
@@ -254,5 +288,19 @@
|
||||
"apiKeysInfo": "Les clés définies ici sont utilisées par défaut. Les projets individuels peuvent les remplacer dans leurs paramètres.",
|
||||
"openaiKey": "Clé API OpenAI",
|
||||
"openaiKeyDescription": "Requise pour le backend mémoire Graphiti (embeddings)"
|
||||
},
|
||||
"debug": {
|
||||
"title": "Debug & Logs",
|
||||
"description": "Accédez aux logs et informations de débogage pour le dépannage",
|
||||
"openLogsFolder": "Ouvrir le dossier des logs",
|
||||
"copyDebugInfo": "Copier les infos de débogage",
|
||||
"copied": "Copié !",
|
||||
"loadInfo": "Charger les infos de débogage",
|
||||
"systemInfo": "Informations système",
|
||||
"logsLocation": "Emplacement des logs",
|
||||
"recentErrors": "Erreurs récentes",
|
||||
"noRecentErrors": "Aucune erreur récente",
|
||||
"helpTitle": "Signaler des problèmes",
|
||||
"helpText": "Lors du signalement de bugs, cliquez sur \"Copier les infos de débogage\" pour obtenir les informations système et les erreurs récentes qui nous aident à diagnostiquer le problème."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { IPCResult } from './common';
|
||||
import type { SupportedIDE, SupportedTerminal } from './settings';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSettings,
|
||||
@@ -149,6 +150,9 @@ export interface ElectronAPI {
|
||||
mergeWorktreePreview: (taskId: string) => Promise<IPCResult<WorktreeMergeResult>>;
|
||||
discardWorktree: (taskId: string) => Promise<IPCResult<WorktreeDiscardResult>>;
|
||||
listWorktrees: (projectId: string) => Promise<IPCResult<WorktreeListResult>>;
|
||||
worktreeOpenInIDE: (worktreePath: string, ide: SupportedIDE, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
|
||||
worktreeOpenInTerminal: (worktreePath: string, terminal: SupportedTerminal, customPath?: string) => Promise<IPCResult<{ opened: boolean }>>;
|
||||
worktreeDetectTools: () => Promise<IPCResult<{ ides: Array<{ id: string; name: string; path: string; installed: boolean }>; terminals: Array<{ id: string; name: string; path: string; installed: boolean }> }>>;
|
||||
|
||||
// Task archive operations
|
||||
archiveTasks: (projectId: string, taskIds: string[], version?: string) => Promise<IPCResult<boolean>>;
|
||||
@@ -604,6 +608,23 @@ export interface ElectronAPI {
|
||||
|
||||
// GitHub API (nested for organized access)
|
||||
github: import('../../preload/api/modules/github-api').GitHubAPI;
|
||||
|
||||
// Debug operations
|
||||
getDebugInfo: () => Promise<{
|
||||
systemInfo: Record<string, string>;
|
||||
recentErrors: string[];
|
||||
logsPath: string;
|
||||
debugReport: string;
|
||||
}>;
|
||||
openLogsFolder: () => Promise<{ success: boolean; error?: string }>;
|
||||
copyDebugInfo: () => Promise<{ success: boolean; error?: string }>;
|
||||
getRecentErrors: (maxCount?: number) => Promise<string[]>;
|
||||
listLogFiles: () => Promise<Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
}>>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user