Compare commits

..

66 Commits

Author SHA1 Message Date
JoshuaRileyDev fef07c9517 feat: add terminal dropdown with inbuilt and external options in task review (#347)
* feat: add dropdown to select inbuilt or external terminal in task review

Replace the single terminal button on the task review modal with a dropdown
menu that allows users to choose between:
- Opening in an inbuilt terminal tab (existing behavior)
- Opening in the system's default external terminal application

Changes:
- Add IPC channel SHELL_OPEN_TERMINAL for opening paths in system terminal
- Create IPC handler with cross-platform support (macOS, Windows, Linux)
- Update ShellAPI with openTerminal method
- Extend useTerminalHandler hook to support both terminal types
- Create TerminalDropdown component with dropdown menu UI
- Update WorkspaceStatus to use dropdown for both terminal buttons

Cross-platform support:
- macOS: Uses 'open -a Terminal' to open Terminal.app
- Windows: Uses 'start cmd' to open Command Prompt
- Linux: Tries common terminal emulators (gnome-terminal, konsole, xfce4-terminal, xterm)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: correct import paths in TerminalDropdown component

* feat: add modal close and view switch for inbuilt terminal option

When opening the inbuilt terminal from the task review modal, the UI now:
- Closes the task detail modal
- Switches to the Agent Terminals view
- Creates the terminal in that view

This provides a better user experience by automatically navigating to where
the terminal is created, rather than keeping the user in the modal.

Changes:
- Added onSwitchToTerminals prop to TaskDetailModal, TaskReview, and WorkspaceStatus
- Updated TerminalDropdown handlers to call onClose and onSwitchToTerminals before creating terminal
- Wired up App.tsx to pass setActiveView callback to TaskDetailModal

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: pass terminal creation callback to parent to prevent unmount race condition

The previous implementation called openTerminal from the WorkspaceStatus component,
but when the modal closed and view switched, the component unmounted before the
terminal could be created.

This fix passes terminal creation parameters up to App.tsx where the modal close,
view switch, and terminal creation are handled in the correct order at the parent level.

Changes:
- Added onOpenInbuiltTerminal callback prop through component hierarchy
  (TaskDetailModal → TaskReview → WorkspaceStatus)
- Created handleOpenInbuiltTerminal in App.tsx that:
  1. Closes the modal (setSelectedTask(null))
  2. Switches to terminals view (setActiveView('terminals'))
  3. Creates the terminal (window.electronAPI.createTerminal)
- Updated TerminalDropdown handlers in WorkspaceStatus to call the callback
  instead of creating terminal directly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add terminal to frontend store to display in UI

The previous implementation created the terminal in the backend but didn't
add it to the frontend terminal store, so TerminalGrid had no terminal to render.

The correct flow is:
1. Add terminal to store (creates Terminal object in frontend)
2. Terminal component mounts
3. usePtyProcess hook creates backend PTY process

Changes:
- Updated handleOpenInbuiltTerminal to use useTerminalStore.getState().addTerminal()
- Removed direct window.electronAPI.createTerminal() call
- Terminal now appears in TerminalGrid after creation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add openTerminal to ElectronAPI type and browser mock

TypeScript was complaining about missing openTerminal method:
- Added openTerminal to ElectronAPI interface in ipc.ts
- Added openTerminal mock to infrastructure-mock.ts for browser mode

This fixes the typecheck errors in CI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: use node: prefix for child_process import for better TypeScript resolution

Changed from 'child_process' to 'node:child_process' to ensure TypeScript
properly resolves the execSync import in all environments including CI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: improve terminal command security, deterministic mounting, and i18n

This commit addresses several issues with the terminal dropdown feature:

1. **Improved Windows Command Security** (settings-handlers.ts):
   - Removed fragile nested quotes from Windows terminal command
   - Changed from `"cd /d "${dirPath}""` to `cd /d "${sanitizedPath}"`
   - Added path sanitization to escape double quotes and prevent command injection
   - Simplified command structure for better reliability

2. **Deterministic Component Readiness** (App.tsx, TerminalGrid.tsx):
   - Replaced hardcoded 100ms timeout with deterministic readiness signal
   - Added `onMounted` prop to TerminalGrid that fires when component mounts
   - Created Promise-based waiting mechanism in handleOpenInbuiltTerminal
   - Checks if terminals view is already active to avoid unnecessary waiting
   - Ensures Terminal component is mounted before creating backend PTY

3. **i18n Compliance** (TerminalDropdown.tsx):
   - Replaced all hardcoded English strings with translation keys
   - Added useTranslation hook with 'taskReview' namespace
   - Updated button title: "Open terminal" → t('terminal.openTerminal')
   - Updated menu items:
     - "Open in Inbuilt Terminal" → t('terminal.openInbuilt')
     - "Open in External Terminal" → t('terminal.openExternal')
   - Created taskReview.json translation files for English and French

Files Changed:
- src/main/ipc-handlers/settings-handlers.ts
- src/renderer/App.tsx
- src/renderer/components/TerminalGrid.tsx
- src/renderer/components/task-detail/task-review/TerminalDropdown.tsx
- src/shared/i18n/locales/en/taskReview.json (new)
- src/shared/i18n/locales/fr/taskReview.json (new)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: use execFileSync with argument arrays to prevent command injection

Security Fix: Replaced all execSync shell commands with execFileSync and
argument arrays to completely eliminate command injection vulnerabilities.

Previous issue:
- Incomplete escaping: only escaped double quotes, not backslashes
- Shell interpretation could lead to command injection with crafted paths

Solution:
- macOS: execFileSync('open', ['-a', 'Terminal', dirPath])
- Windows: execFileSync('cmd.exe', ['/K', 'cd', '/d', dirPath], {shell: false})
- Linux: execFileSync(terminal, ['--working-directory', dirPath])

Benefits:
- No shell interpretation - arguments passed directly to executables
- No escaping needed - OS handles path special characters correctly
- Prevents all forms of command injection
- More reliable cross-platform behavior

For xterm (Linux fallback), single quotes are properly escaped using the
pattern: dirPath.replace(/'/g, "'\\''") which handles single quotes in paths.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: register taskReview namespace with i18n configuration

The taskReview translation files were created but not registered with the
i18n configuration, causing translation keys to be displayed literally
instead of the actual translated text.

Changes:
- Imported enTaskReview and frTaskReview translation files
- Added taskReview to resources object for both en and fr
- Added 'taskReview' to the ns (namespaces) array in i18n.init()

This fixes the dropdown menu displaying "terminal.openInbuilt" instead of
"Open in Inbuilt Terminal".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: remove unused onMounted callback and Promise-based readiness signaling

- Remove handleTerminalGridMounted function reference that caused runtime error
- Remove onMounted prop from TerminalGrid interface
- Remove useEffect hook that called onMounted callback
- Simplify handleOpenInbuiltTerminal to directly add terminal to store
- TerminalGrid is always mounted (just hidden), so no readiness signaling needed

This fixes "handleTerminalGridMounted is not defined" error and simplifies
the terminal creation flow.

* chore: remove unused imports (useRef, useCallback) from App.tsx

* refactor: add input validation and remove unused import in terminal handler

Security and code quality improvements:

1. Add comprehensive input validation for dirPath:
   - Check for non-empty string
   - Resolve to absolute path with path.resolve()
   - Verify path exists with existsSync()
   - Confirm it's a directory with statSync().isDirectory()
   - Return clear, actionable error messages if any check fails

2. Replace all uses of dirPath with validated resolvedPath

3. Remove unused execSync import from node:child_process

4. Add statSync to fs imports for directory validation

This prevents potential issues with invalid paths and improves error
handling with specific error messages for each validation failure.

* refactor: rename unused id parameter to _id in handleOpenInbuiltTerminal

- Prefix parameter with underscore to indicate intentionally unused
- Add comment explaining terminal ID is auto-generated by addTerminal()
- Keep parameter for callback signature consistency with callers
- Remove id from console.log since it's not used in the logic

This satisfies linter requirements while maintaining callback compatibility.

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 17:29:29 +01:00
Mitsu 9d43abedde refactor: remove deprecated code across backend and frontend (#348)
## Backend
- Delete `agents/auto_claude_tools.py` (compatibility shim)
- Delete `implementation_plan/main.py` (compatibility shim)
- Remove `--dev` flag and `dev_mode` parameter from:
  - cli/main.py, cli/utils.py, cli/spec_commands.py
  - runners/spec_runner.py
  - spec/pipeline/models.py, orchestrator.py
  - spec/complexity.py
- Remove `ClaudeSimilarityDetector` class from batch_issues.py
- Remove unused `self.detector` alias

## Frontend
- Remove `PROJECT_UPDATE_AUTOBUILD` IPC channel
- Remove `updateProjectAutoBuild` from:
  - project-handlers.ts (IPC handler)
  - project-api.ts (preload API)
  - project-store.ts (store function)
  - project-mock.ts (mock)
- Remove deprecated `appendOutput`/`clearOutputBuffer` from terminal-store
- Update useTerminalEvents to use terminalBufferManager directly
- Remove deprecated "Update Auto Claude" dialog from Sidebar
- Remove `handleUpdate` from useProjectSettings hook

## Tests
- Remove `test_dev_mode_param_ignored` test
2025-12-27 16:33:26 +01:00
HSSAINI Saad d51f45621b feat: centralize CLI tool path management (#341)
* feat: centralize CLI tool path management

Created centralized CLI tool manager with multi-level detection
priority (user config → venv → Homebrew → system PATH).

Key changes:
- New cli-tool-manager.ts with platform-aware detection (macOS
  Apple Silicon/Intel, Windows, Linux)
- Migrated 75 hardcoded CLI tool usages across 12 files (Python,
  Git, GitHub CLI)
- Added Settings UI for user configuration with auto-detection
  display showing detected path, version, and source
- Implemented version validation (Python 3.10+ required)
- Session-based caching without TTL expiration
- Full i18n support (EN/FR) for new Settings UI elements

This eliminates hardcoded tool paths and provides consistent,
configurable CLI tool management across the application.

* fix(lint): resolve linting issues in CLI tool manager

- Remove unused getAugmentedEnv import
- Replace console.log with console.warn for logging
- Fix template string syntax error in release-service.ts (backtick vs quote)

Reduces lint errors from 185 to 179 (0 errors, 179 warnings)

* fix(security): address CodeRabbit review feedback

- Fix command injection vulnerability in validateGitHubCLI using execFileSync
- Remove redundant execSync calls in release-service.ts
- Fix Electron context separation by moving ToolDetectionResult to shared types
- Add loading state to Settings UI to prevent flashing 'Not detected'
- Improve error handling with safe string conversion

Addresses security and code quality issues identified by automated review.

* fix(security): fix all command injection vulnerabilities in CLI tool usage

Replace all execSync calls using getToolPath() with execFileSync to prevent
command injection attacks. This fixes CodeQL security warnings about unsanitized
environment variables in command execution.

Changes:
- settings-handlers.ts: 1 fix (git init)
- release-service.ts: 20 fixes (git/gh commands in release flow)
- worktree-handlers.ts: 22 fixes (git worktree operations)
- project-handlers.ts: 3 fixes (git branch operations)
- github/utils.ts: 1 fix (gh auth token)
- github/oauth-handlers.ts: 11 fixes (gh API and git remote operations)
- github/release-handlers.ts: 3 fixes (gh auth, git describe, git log)
- changelog/git-integration.ts: 6 fixes (git branch and tag operations)

Total: 67 command injection vulnerabilities fixed

Security Impact:
- Prevents malicious users from injecting arbitrary commands via CLI tool paths
- Uses execFileSync which executes binaries directly without shell interpolation
- Passes arguments as array instead of concatenated string
- Eliminates shell redirection patterns (2>/dev/null) with try-catch blocks

* fix(security): fix remaining command injection vulnerabilities and remove unused imports

Fix all remaining CodeQL security warnings:

CLI tool validation (cli-tool-manager.ts):
- validateGit: Replace execSync with execFileSync for git --version

Project initialization (project-initializer.ts):
- Replace all 7 execSync calls with execFileSync
- git rev-parse, git init, git status, git add, git commit

Release service (release-service.ts):
- checkTagExists: Fix git tag -l and git ls-remote
- getGitHubReleaseUrl: Fix gh release view
- Fix worktree merge detection (2 more git commands)
- Remove unused execSync import

Code cleanup:
- Remove unused execSync imports from:
  - github/utils.ts
  - project-handlers.ts
  - settings-handlers.ts
  - release-service.ts

Total: 12 additional command injection fixes + 4 unused imports removed

This completes the security audit with 0 remaining vulnerabilities.

* refactor(code-quality): remove useless variable assignments

Fix CodeQL warnings about unused initial variable values:
- mainBranch: Declare without initial value, assign in try-catch
- unmergedCommits: Declare without initial value, assign in try-catch

The initial values were never used since they were always overwritten
either in the try block (success) or catch block (error fallback).

* test(security): update oauth-handlers tests to use execFileSync mocks

Update test mocks in oauth-handlers.spec.ts to match the security fix
that changed from execSync to execFileSync. All 525 tests now passing.

Changes:
- gh CLI Check Handler tests: Use mockExecFileSync with array args
- gh Auth Check Handler tests: Use mockExecFileSync with array args
- Fixed argument pattern: cmd + args array instead of single command string

Related to command injection prevention in oauth-handlers.ts
2025-12-27 15:53:49 +01:00
Mitsu 787667e98e refactor(components): remove deprecated TaskDetailPanel re-export (#344)
Remove the backwards compatibility re-export file `TaskDetailPanel.tsx`
that was only re-exporting from `./task-detail/`. This file was unused -
the app imports directly from `./task-detail/TaskDetailModal`.

Removed:
- `src/renderer/components/TaskDetailPanel.tsx` - unused re-export file
- Export from `index.ts`
2025-12-27 15:30:32 +01:00
souky-byte 9734b70b05 chore: Refactor/kanban realtime status sync (#249)
* fix(execution): add structured phase event emission and improve phase transition handling

- Add emit_phase() calls in coder.py for PLANNING, CODING, COMPLETE, and FAILED phases
- Add emit_phase() calls in planner.py for follow-up planning phase
- Add emit_phase() calls in qa/loop.py for QA_REVIEW, QA_FIXING, COMPLETE, and FAILED phases
- Add parsePhaseEvent() to agent-events.ts to prioritize structured events over log parsing
- Default to 'planning' phase in PhaseProgressIndicator when running but phase

* fix(execution): prevent premature 'complete' phase during QA workflow

- Remove COMPLETE phase emission from coder.py when subtasks finish (QA hasn't run yet)
- Add phase regression prevention in agent-events.ts to block fallback text matching from moving backwards (e.g., QA → coding)
- Remove 'complete' phase detection from "BUILD COMPLETE" banner text (only structured emit_phase(COMPLETE) from QA approval should set complete)
- Add line buffering in agent-process.ts to prevent split __EXEC_PHASE

* fix(execution): prevent phase regression and improve JSON parsing robustness

- Add phase regression check to 'planning' phase detection in agent-events.ts
- Prevent 'failed' phase from overwriting 'complete' or 'failed' from structured events in agent-process.ts
- Add extractJsonObject() to handle JSON with trailing garbage in phase-event-parser.ts
  - Implements brace-matching parser that handles escaped quotes and nested objects
  - Prevents parse failures when __EXEC_PHASE__ JSON is followed by log

* refactor: improve variable naming clarity in phase event parsing

- Rename 'escape' to 'isEscaped' in extractJsonObject() for better readability
- Rename list comprehension variable 'l' to 'line' in test_phase_event.py

* feat(agent-events): add Zod validation and refactor phase event parsing

- Add zod dependency (^4.2.1) for runtime type validation
- Refactor phase event parsing into specialized parser classes:
  - ExecutionPhaseParser for task execution phases
  - IdeationPhaseParser for ideation workflow phases
  - RoadmapPhaseParser for roadmap generation phases
- Add strict Zod schemas for phase event validation:
  - Reject invalid message types (must be string)
  - Reject invalid progress values (must be 0-100

* refactor(agent-events): remove parser delegation and inline phase detection logic

- Remove ExecutionPhaseParser, IdeationPhaseParser, and RoadmapPhaseParser delegation
- Inline all phase detection logic directly into AgentEvents methods
- Add wouldPhaseRegress() check to prevent fallback text matching from moving backwards
- Add parsePhaseEvent() call to prioritize structured __EXEC_PHASE__ events
- Add checkRegression() helper to validate phase transitions before applying fallback matches
- Filter

* test(subprocess): update test expectations to include newlines in buffered output

- Update subprocess-spawn.test.ts to append '\n' to test data and expectations
- Reflects line buffering behavior where output is processed line-by-line
- Skip ipc-handlers.test.ts exit event test (status change logic removed)
- Remove exit code 0 test case that no longer applies after status change removal

* refactor(ideation-phase-parser): add terminal state guard and extract progress calculation

- Add terminal state check to prevent phase changes after completion
- Extract calculateGeneratingProgress() helper with division-by-zero protection
- Return 90% progress fallback when totalTypes is 0 or negative
- Apply helper to both progress calculation paths (no phase change and phase detected)

* fix(phase-parser): prevent premature QA phase detection during planning

Add canEnterQAPhase guard to fallback text matching in agent-events.ts
and execution-phase-parser.ts. QA phases can now only be triggered via
text matching if currentPhase is already 'coding', 'qa_review', or
'qa_fixing'. This prevents tasks from jumping to QA Review column when
planning phase output contains QA-related text.

Structured events from backend (__EXEC_PHASE__:...) bypass this check.

* fix(task-store): prevent stale plan data from overriding status during active execution

When a task is restarted, the file watcher immediately reads the existing
implementation_plan.json and calls updateTaskFromPlan. If the old plan has
all subtasks completed, it would set status to 'ai_review' before the agent
process emits the 'planning' phase event.

This fix checks if the task is in an active execution phase (planning,
coding, qa_review, qa_fixing) and if so, does NOT let the plan data
override the status. The execution phase takes precedence.

Added 4 tests to verify the behavior.

* Reorder imports in coder.py for clarity

Moved the import of ExecutionPhase and emit_phase from phase_event to follow the project's import organization conventions and improve code readability.

* fix: address PR review comments from CodeRabbit

- Clamp progress values to 0-100 range in phase_event.py
- Remove unused PhaseEvent import in test file
- Simplify terminal phase check in ideation-phase-parser.ts
- Add regression prevention in roadmap-phase-parser.ts
- Use z.infer for PhaseEvent type derivation

* fix: add type assertions for Zod-validated phase values

TypeScript couldn't infer the literal type from Zod enum validation.
Added explicit type assertions since the phase is already validated.

* fix: correct misleading test name for QA loop transition

The test was named 'should not regress' but actually verified that
qa_fixing → qa_review IS allowed (valid re-review after fix).
Renamed to clarify the expected behavior.

* fix: define phase variable from rawPhase in PhaseProgressIndicator

The prop was renamed during destructuring but the derived variable
was never defined, causing 'phase is not defined' runtime error.

* fix(security): add Python path validation to prevent command injection

Add validatePythonPath() function that validates user-configurable Python
paths before use in spawn(). This prevents potential command injection
attacks via malicious paths.

Security checks implemented:
- Block shell metacharacters (;|&<> etc.)
- Validate against allowlist of known Python locations
- Verify file exists and is executable
- Confirm it's actually Python via --version

Applied validation to all affected locations:
- AgentProcessManager.configure()
- InsightsConfig.configure()
- ChangelogService.configure()
- TitleGenerator.configure()

Addresses: PR #249 review - CRITICAL security finding

* fix: add sequence tracking to prevent race conditions in state updates

Add sequenceNumber field to ExecutionProgress to track update order and
prevent stale updates from overwriting newer state.

Changes:
- Add sequenceNumber to ExecutionProgress interface
- updateExecutionProgress now rejects updates with lower sequence numbers
- All execution-progress emissions now include monotonically increasing
  sequence numbers

This prevents race conditions where out-of-order updates could cause
incorrect task state display.

Addresses: PR #249 review - HIGH severity race condition finding

* refactor: extract helper methods from spawnProcess() to reduce complexity

Break down the 294-line spawnProcess() into smaller focused methods:
- setupProcessEnvironment(): Creates the process environment object
- handleProcessFailure(): Orchestrates rate limit and auth failure handling
- handleRateLimitWithAutoSwap(): Handles auto-swap logic for rate limits
- handleAuthFailure(): Detects and handles authentication failures

The main spawnProcess() is now significantly cleaner with single-responsibility
helper methods that are easier to test and maintain.

Addresses: PR #249 review - HIGH severity complexity finding

* fix: improve phase handling with type guards and better error reporting

- Add type guard validation in checkRegression() before calling
  wouldPhaseRegress() to prevent undefined lookups in PHASE_ORDER_INDEX
- Add warning log when calculateOverallProgress() receives unknown phase
  instead of silently returning 0%
- Change 'failed' phase index from 5 to 99 to clearly indicate it's
  outside normal progression (like 'idle' uses -1)

These changes improve defensive programming and debugging capabilities
for phase state management.

Addresses: PR #249 review - MEDIUM severity findings

* refactor(security): consolidate Python path validation logic into reusable helper

Extract repeated validation pattern into getValidatedPythonPath() helper to reduce code duplication across services.

Changes:
- Add getValidatedPythonPath() helper that encapsulates validation logic
- Replace duplicated validation blocks in ChangelogService, InsightsConfig, and TitleGenerator with helper call
- Improve isSafePythonCommand() to normalize whitespace before checking
- Add newline/carriage return to DANGEROUS_SHELL_CHARS regex

* fix(tests): enable exit event forwarding test

- Remove it.skip from 'should forward exit events with status change on failure'
- Add proper test setup: create project and task before emitting exit event
- Add mock for notificationService to prevent errors during test

* fix(security): use mkdtempSync for secure temp directory in tests

Addresses CodeQL 'Insecure temporary file' warning by using
mkdtempSync with a random suffix instead of a predictable path.

---------

Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
2025-12-27 15:21:35 +01:00
Mitsu fec6b9f339 refactor(settings): remove deprecated ProjectSettings modal and hooks (#343)
The old ProjectSettings modal has been replaced by the unified AppSettings
dialog. This removes:

- `ProjectSettings.tsx` - deprecated modal component
- `project-settings/ProjectSettings.tsx` - unused refactored version
- `hooks/useProjectSettings.ts` - replaced by project-settings/hooks/
- `hooks/useEnvironmentConfig.ts` - only used by deprecated modal
- `hooks/useClaudeAuth.ts` - only used by deprecated modal
- `hooks/useLinearConnection.ts` - only used by deprecated modal
- `hooks/useGitHubConnection.ts` - only used by deprecated modal
- `hooks/useInfrastructureStatus.ts` - only used by deprecated modal

Updated index files to remove deprecated exports.
2025-12-27 15:02:58 +01:00
JoshuaRileyDev d3a63b09fd perf: convert synchronous I/O to async operations in worktree handlers (#337)
This commit optimizes the merge handler to prevent UI blocking by converting synchronous file operations to asynchronous I/O:

- Make handleProcessExit async to support async operations
- Replace readFileSync with fsPromises.readFile for commit message reading
- Refactor plan persistence to use async I/O with parallel updates via Promise.all()
- Implement fire-and-forget pattern for plan updates to prevent blocking the response
- Improve error handling with separate tracking for main vs worktree plans

These changes eliminate blocking I/O operations that could freeze the UI during merge operations, particularly when updating implementation plans across both the main project and worktree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 14:13:53 +01:00
Alex 50e3111ae2 feat: bump version (#329) 2025-12-26 22:55:47 +01:00
Michael Ludlow 8a80b1d5c8 fix(ci): remove version bump to fix branch protection conflict (#325) 2025-12-26 22:05:27 +01:00
Alex cb6b216534 fix(tasks): sync status to worktree implementation plan to prevent reset (#243) (#323)
Co-authored-by: Black Circle Sentinel <mludlow000@icloud.com>
2025-12-26 21:19:41 +01:00
Michael Ludlow 661e47c341 fix(ci): add auto-updater manifest files and version auto-update (#317)
Combined PR with:
1. Alex's version auto-update changes from PR #316
2. Auto-updater manifest file generation fix

Changes:
- Add --publish never to package scripts to generate .yml manifests
- Update all build jobs to upload .yml files as artifacts
- Update release step to include .yml files in GitHub release
- Auto-bump version in package.json files before tagging

This enables the in-app auto-updater to work properly by ensuring
latest-mac.yml, latest-linux.yml, and latest.yml are published
with each release.

Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
2025-12-26 19:21:32 +01:00
Michael Ludlow e80ef79d12 fix(project): fix task status persistence reverting on refresh (#246) (#318)
Correctly validate persisted task status against calculated status.
Previously, if a task was 'in_progress' but had no active subtasks (e.g. still in planning or between phases),
the calculated status 'backlog' would override the stored status, causing the UI to revert to 'Start'.

This fix adds 'in_progress' to the list of active process statuses and explicitly allows 'in_progress' status
to persist when the underlying plan status is also 'in_progress'.
2025-12-26 19:18:36 +01:00
Michael Ludlow e1b0f743bf fix(updater): proper semver comparison for pre-release versions (#313)
Fixes the beta auto-update bug where the app was offering downgrades
(e.g., showing v2.7.1 as "new version available" when on v2.7.2-beta.6).

Changes:
- version-manager.ts: New parseVersion() function that separates base
  version from pre-release suffix. Updated compareVersions() to handle
  pre-release versions correctly (alpha < beta < rc < stable).
- app-updater.ts: Import and use compareVersions() for proper version
  comparison instead of simple string inequality.
- Added comprehensive unit tests for version comparison logic.

Pre-release ordering:
- 2.7.1 < 2.7.2-alpha.1 < 2.7.2-beta.1 < 2.7.2-rc.1 < 2.7.2 (stable)
- 2.7.2-beta.6 < 2.7.2-beta.7
2025-12-26 17:48:32 +01:00
Alex 92c6f2786d fix(python): use venv Python for all services to fix dotenv errors (#311)
* fix(python): use venv Python for all services to fix dotenv errors

Services were spawning Python processes using findPythonCommand() which
returns the bundled Python directly. However, dependencies like python-dotenv
are only installed in the venv created from the bundled Python.

Changes:
- Add getConfiguredPythonPath() helper that returns venv Python when ready
- Update all services to use venv Python instead of bundled Python directly:
  - memory-service.ts
  - memory-handlers.ts
  - agent-process.ts
  - changelog-service.ts
  - title-generator.ts
  - insights/config.ts
  - project-context-handlers.ts
  - worktree-handlers.ts
- Fix availability checks to use findPythonCommand() (can return null)
- Add python:verify script for bundling verification

The flow now works correctly:
1. App starts → findPythonCommand() finds bundled Python
2. pythonEnvManager creates venv using bundled Python
3. pip installs dependencies (dotenv, claude-agent-sdk, etc.)
4. All services use venv Python → has all dependencies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix lintin and test

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-26 17:14:57 +01:00
Oluwatosin Oyeladun 1c14227324 chore(ci): cancel in-progress runs (#302)
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
2025-12-26 15:21:26 +01:00
Andy c0a02a453d fix(build): use explicit Windows System32 tar path (#308)
The previous PowerShell fix still found Git Bash's /usr/bin/tar which
interprets D: as a remote host. Using the explicit path to Windows'
built-in bsdtar (C:\Windows\System32\tar.exe) avoids this issue.

Windows Server 2019+ (GitHub Actions) has bsdtar in System32.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:36:20 +01:00
AndyMik90 086429cb49 fix(github): add augmented PATH env to all gh CLI calls
When running from a packaged macOS app (.dmg), the PATH environment
variable doesn't include common locations like /opt/homebrew/bin where
gh is typically installed via Homebrew.

The getAugmentedEnv() function was already being used in some places
but was missing from:
- spawn() call in registerStartGhAuth
- execSync calls for gh auth token, gh api user, gh repo list
- execFileSync calls for gh api, gh repo create
- execFileSync calls in pr-handlers.ts and triage-handlers.ts

This caused "gh: command not found" errors when connecting projects
to GitHub in the packaged app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:19:54 +01:00
AndyMik90 d9fb8f29d5 fix(build): use PowerShell for tar extraction on Windows
The previous fix using --force-local and path conversion still failed
due to shell escaping issues. PowerShell handles Windows paths natively
and has built-in tar support on Windows 10+, avoiding all path escaping
problems.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:15:15 +01:00
Andy d0b0b3df0d fix(build): add --force-local flag to tar on Windows (#303)
On Windows, paths like D:\path are misinterpreted by tar as remote
host:path syntax (Unix tar convention). Adding --force-local tells
tar to treat colons as part of the filename, fixing the extraction
failure in GitHub Actions Windows builds.

Error was: "tar (child): Cannot connect to D: resolve failed"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:55:47 +01:00
Andy 937a60f8bd fix: stop tracking spec files in git (#295)
* fix: stop tracking spec files in git

- Remove git commit instructions from planner.md for spec files
- Spec files (implementation_plan.json, init.sh, build-progress.txt) should be gitignored
- Untrack existing spec files that were accidentally committed
- AI agents should only commit code changes, not spec metadata

The .auto-claude/specs/ directory is gitignored by design - spec files are
local project metadata that shouldn't be version controlled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(prompts): remove git commit instructions for gitignored spec files

The spec files (build-progress.txt, qa_report.md, implementation_plan.json,
QA_FIX_REQUEST.md) are all stored in .auto-claude/specs/ which is gitignored.

Removed instructions telling agents to commit these files, replaced with
notes explaining they're tracked automatically by the framework.

🤖 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>
2025-12-26 13:51:24 +01:00
Andy 7a51cbd5e5 Fix/2.7.2 fixes (#300)
* 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>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:47:19 +01:00
Andy 26beefe39d feat(merge,oauth): add path-aware AI merge resolution and device code streaming (#296)
* improve/merge-confclit-layer

* improve AI resolution

* fix caching on merge conflicts

* imrpove merge layer with rebase

* fix(github): add OAuth authentication to follow-up PR review

The follow-up PR review AI analysis was failing with "Could not resolve
authentication method" because AsyncAnthropic() was instantiated without
credentials. The codebase uses OAuth tokens (not ANTHROPIC_API_KEY), so
the client needs the auth_token parameter.

Uses get_auth_token() from core.auth to retrieve the OAuth token from
environment variables or macOS Keychain, matching how initial reviews
authenticate via create_client().

* fix(merge): add validation to prevent AI writing natural language to files

When AI merge receives truncated file contents (due to character limits),
it sometimes responds with explanations like "I need to see the complete
file contents..." instead of actual merged code. This garbage was being
written directly to source files.

Adds two validation layers after AI merge:
1. Natural language detection - catches patterns like "I need to", "Let me"
2. Syntax validation - uses esbuild to verify TypeScript/JavaScript syntax

If either validation fails, the merge returns an error instead of writing
invalid content to the file.

Also adds project_dir field to ParallelMergeTask to enable syntax validation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(merge): skip git merge when AI already resolved path-mapped files

When AI successfully merges path-mapped files (due to file renames
between branches), the check only looked at `conflicts_resolved` which
was 0 for path-mapped cases. This caused the code to fall through to
`git merge` which then failed with conflicts.

Now also checks `files_merged` and `ai_assisted` stats to determine
if AI has already handled the merge. When files are AI-merged, they're
already written and staged - no need for git merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix device code issue with github

* fix(frontend): remember GitHub auth method (OAuth vs PAT) in settings

Previously, after authenticating via GitHub OAuth, the settings page
would show "Personal Access Token" input even though OAuth was used.
This was confusing for users who expected to see their OAuth status.

Added githubAuthMethod field to track how authentication was performed.
Settings UI now shows "Authenticated via GitHub OAuth" when OAuth was
used, with option to switch to manual token if needed. The auth method
persists across settings reopening.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(backend): centralize OAuth client creation in core/client.py

- Add create_message_client() for simple message API calls
- Refactor followup_reviewer.py to use centralized client factory
- Remove direct anthropic.AsyncAnthropic import from followup_reviewer
- Add proper ValueError handling for missing OAuth token
- Update docstrings to document both client factories

This ensures all AI interactions use the centralized OAuth authentication
in core/, avoiding direct ANTHROPIC_API_KEY usage per CLAUDE.md guidelines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(frontend): remove unused statusColor variable in WorkspaceStatus

Dead code cleanup - the statusColor variable was computed but never
used in the component.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): add BrowserWindow mock to oauth-handlers tests

The sendDeviceCodeToRenderer function uses BrowserWindow.getAllWindows()
which wasn't mocked, causing unhandled rejection errors in tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(backend): use ClaudeSDKClient instead of raw anthropic SDK

Remove direct anthropic SDK import from core/client.py and update
followup_reviewer.py to use ClaudeSDKClient directly as per project
conventions. All AI interactions should use claude-agent-sdk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(backend): add debug logging for AI response in followup_reviewer

Add logging to diagnose why AI review returns no JSON - helps identify
if response is in thinking blocks vs text blocks.

🤖 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>
2025-12-26 13:42:45 +01:00
Alex 8416f3076a feat: enhance the logs for the commit linting stage (#293)
* ci: implement enterprise-grade PR quality gates and security scanning

* ci: implement enterprise-grade PR quality gates and security scanning

* fix:pr comments and improve code

* fix: improve commit linting and code quality

* Removed the dependency-review job (i added it)

* fix: address CodeRabbit review comments

- Expand scope pattern to allow uppercase, underscores, slashes, dots
- Add concurrency control to cancel duplicate security scan runs
- Add explanatory comment for Bandit CLI flags
- Remove dependency-review job (requires repo settings)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update commit lint examples with expanded scope patterns

Show slashes and dots in scope examples to demonstrate
the newly allowed characters (api/users, package.json)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove feature request issue template

Feature requests are directed to GitHub Discussions
via the issue template config.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address security vulnerabilities in service orchestrator

- Fix port parsing crash on malformed docker-compose entries
- Fix shell injection risk by using shlex.split() with shell=False

Prevents crashes when docker-compose.yml contains environment
variables in port mappings (e.g., '${PORT}:8080') and eliminates
shell injection vulnerabilities in subprocess execution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(ci): improve PR title validation error messages with examples

Add helpful console output when PR title validation fails:
- Show expected format and valid types
- Provide examples of valid PR titles
- Display the user's current title
- Suggest fixes based on keywords in the title
- Handle verb variations (fixed, adding, updated, etc.)
- Show placeholder when description is empty after cleanup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* lower coverage

* feat: improve status gate to label correctly based on required checks

* fix(ci): address PR review findings for security and efficiency

- Add explicit permissions block to ci.yml (least privilege principle)
- Skip duplicate test run for Python 3.12 (tests with coverage only)
- Sanitize PR title in markdown output to prevent injection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix typo

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 10:31:27 +01:00
Andy 217249c8a3 fix(github): add explicit GET method to gh api comment fetches (#294)
The gh api command defaults to POST for comment endpoints, causing
GitHub to reject the 'since' query parameter as an invalid POST body
field. Adding --method GET explicitly forces a GET request, allowing
the since parameter to work correctly for fetching comments.

This completes the fix started in f1cc5a09 which only changed from
-f flag to query string syntax but didn't address the HTTP method.
2025-12-26 09:24:01 +01:00
Andy 8bb3df917e fix(frontend): support archiving tasks across all worktree locations (#286)
* archive across all worktress and if not in folder

* fix(frontend): address PR security and race condition issues

- Add taskId validation to prevent path traversal attacks
- Fix TOCTOU race conditions in archiveTasks by removing existsSync
- Fix TOCTOU race conditions in unarchiveTasks by removing existsSync

🤖 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>
2025-12-26 09:01:31 +01:00
Andy 5106c6e9b2 Potential fix for code scanning alert no. 224: Uncontrolled command line (#285)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-26 08:53:14 +01:00
Andy 3ff612742f fix(frontend): validate backend source path before using it (#287)
* fix(frontend): validate backend source path before using it

The path resolver was returning invalid autoBuildPath settings without
validating they contained the required backend files. When settings
pointed to a legacy /auto-claude/ directory (missing requirements.txt
and analyzer.py), the project indexer would fail with "can't open file"
errors.

Now validates that all source paths contain requirements.txt before
returning them, falling back to bundled source path detection when
the configured path is invalid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: Initialize subtask-based implementation plan

- Workflow type: feature
- Phases: 4
- Subtasks: 9
- Ready for autonomous implementation

Parallel execution enabled: phases 1 and 2 can run simultaneously

* auto-claude: Initialize subtask-based implementation plan

- Workflow type: investigation
- Phases: 5
- Subtasks: 13
- Ready for autonomous implementation

* fix merge conflict check loop

* fix(frontend): add warning when fallback path is also invalid

Address CodeRabbit review feedback - the fallback path in
getBundledSourcePath() was returning an unvalidated path which could
still cause the same analyzer.py error. Now logs a warning when the
fallback path also lacks requirements.txt.

🤖 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>
2025-12-26 08:51:06 +01:00
Andy 7f19c2e1f3 feat(python): bundle Python 3.12 with packaged Electron app (#284)
* feat(python): bundle Python 3.12 with packaged Electron app

Resolves issue #258 where users with Python aliases couldn't run the app
because shell aliases aren't visible to Electron's subprocess calls.

Changes:
- Add download-python.cjs script to fetch python-build-standalone
- Bundle Python 3.12.8 in extraResources for packaged apps
- Update python-detector.ts to prioritize bundled Python
- Add Python caching to CI workflows for faster builds

Packaged apps now include Python (~35MB), eliminating the need for users
to have Python installed. Dev mode still falls back to system Python.

Closes #258

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback for Python bundling

Security improvements:
- Add SHA256 checksum verification for downloaded Python binaries
- Replace execSync with spawnSync to prevent command injection
- Add input validation to prevent log injection from CLI args
- Add download timeout (5 minutes) and redirect limit (10)
- Proper file/connection cleanup on errors

Bug fixes:
- Fix platform naming mismatch: use "mac"/"win" (electron-builder)
  instead of "darwin"/"win32" (Node.js) for output directories
- Handle empty path edge case in parsePythonCommand

Improvements:
- Add restore-keys to CI cache steps for better cache hit rates
- Improve error messages and logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix mac node.js naming

* security: add SHA256 checksums for all Python platforms

Fetched actual checksums from python-build-standalone release:
- darwin-arm64: abe1de24...
- darwin-x64: 867c1af1...
- win32-x64: 1a702b34...
- linux-x64: 698e53b2...
- linux-arm64: fb983ec8...

All platforms now have cryptographic verification for downloaded
Python binaries, eliminating the supply chain risk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: add python-runtime to root .gitignore

Ensures bundled Python runtime is ignored from both root and
frontend .gitignore files.

🤖 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>
2025-12-25 22:38:05 +01:00
Todd W. Bucy d98e28305d fix: resolve spawn python ENOENT error on Linux by using getAugmentedEnv() (#281)
- Use getAugmentedEnv() in project-context-handlers.ts to ensure Python is in PATH
- Add /usr/bin and /usr/sbin to Linux paths in env-utils.ts for system Python
- Fixes GUI-launched apps not inheriting shell environment on Ubuntu 24.04

Fixes #215

Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 22:26:04 +01:00
AndyMik90 0b874d4b33 fix(ci): add write permissions to beta-release update-version job
The update-version job needs contents: write permission to push the
version bump commit and tag to the repository. Without this, the
workflow fails with a 403 error when trying to git push.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 21:34:38 +01:00
dependabot[bot] 50dd10788a chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend (#270)
* chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend

Bumps [@xterm/xterm](https://github.com/xtermjs/xterm.js) from 5.5.0 to 6.0.0.
- [Release notes](https://github.com/xtermjs/xterm.js/releases)
- [Commits](https://github.com/xtermjs/xterm.js/compare/5.5.0...6.0.0)

---
updated-dependencies:
- dependency-name: "@xterm/xterm"
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(deps): update xterm addons for 6.0.0 compatibility and use public APIs

CRITICAL: Updated all xterm addons to versions compatible with xterm 6.0.0:
- @xterm/addon-fit: ^0.10.0 → ^0.11.0
- @xterm/addon-serialize: ^0.13.0 → ^0.14.0
- @xterm/addon-web-links: ^0.11.0 → ^0.12.0
- @xterm/addon-webgl: ^0.18.0 → ^0.19.0

HIGH: Refactored scroll-controller.ts to use public xterm APIs:
- Replaced internal _core access with public buffer/scroll APIs
- Uses onScroll and onWriteParsed events for scroll tracking
- Uses scrollLines() for scroll position restoration
- Proper IDisposable cleanup for event listeners
- Falls back gracefully if onWriteParsed is not available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: AndyMik90 <andre@mikalsenutvikling.no>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 21:05:35 +01:00
AndyMik90 f1cc5a09f2 fix(github): resolve follow-up review API issues
- Fix gh_client.py: use query string syntax for `since` parameter instead
  of `-f` flag which sends POST body fields, causing GitHub API errors
- Fix followup_reviewer.py: use raw Anthropic client for message API calls
  instead of ClaudeSDKClient which is for agent sessions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:53:03 +01:00
Andy b005fa5c86 fix(security): resolve CodeQL file system race conditions and unused variables (#277)
* fix(security): resolve CodeQL file system race conditions and unused variables

Fix high severity CodeQL alerts:
- Remove TOCTOU (time-of-check-time-of-use) race conditions by eliminating
  existsSync checks followed by file operations. Use try-catch instead.
- Files affected: pr-handlers.ts, spec-utils.ts

Fix unused variable warnings:
- Remove unused imports (FeatureModelConfig, FeatureThinkingConfig,
  withProjectSyncOrNull, getBackendPath, validateRunner, githubFetch)
- Prefix intentionally unused destructured variables with underscore
- Remove unused local variables (existing, actualEvent)
- Files affected: pr-handlers.ts, autofix-handlers.ts, triage-handlers.ts,
  PRDetail.tsx, pr-review-store.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): resolve remaining CodeQL alerts for TOCTOU, network data validation, and unused variables

Address CodeRabbit and CodeQL security alerts from PR #277 review:

- HIGH: Fix 12+ file system race conditions (TOCTOU) by replacing
  existsSync() checks with try/catch blocks in pr-handlers.ts,
  autofix-handlers.ts, triage-handlers.ts, and spec-utils.ts
- MEDIUM: Add sanitizeNetworkData() function to validate/sanitize
  GitHub API data before writing to disk, preventing injection attacks
- Clean up 20+ unused variables, imports, and useless assignments
  across frontend components and handlers
- Fix Python Protocol typing in testing.py (add return type annotations)

All changes verified with TypeScript compilation and ESLint (no errors).

🤖 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>
2025-12-25 20:52:22 +01:00
Andy d79f2da411 fix(ci): use correct electron-builder arch flags (#278)
The project switched from pnpm to npm, which handles script argument
passing differently. pnpm adds a -- separator that caused electron-builder
to ignore the --arch argument, but npm passes it directly.

Since --arch is a deprecated electron-builder argument, use the
recommended flags instead:
- --arch=x64 → --x64
- --arch=arm64 → --arm64

This fixes Mac Intel and ARM64 builds failing with "Unknown argument: arch"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 20:31:57 +01:00
dependabot[bot] 5ac566e2a2 chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/frontend (#268)
Bumps [jsdom](https://github.com/jsdom/jsdom) from 26.1.0 to 27.3.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/26.1.0...27.3.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 27.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 20:05:29 +01:00
dependabot[bot] f49d4817b1 chore(deps): bump typescript-eslint in /apps/frontend (#269)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.49.0 to 8.50.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.50.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 20:03:05 +01:00
Andy 1e1d7d9b68 fix(ci): use develop branch for dry-run builds in beta-release workflow (#276)
When dry_run=true, the workflow skipped creating the version tag but
build jobs still tried to checkout that non-existent tag, causing all
4 platform builds to fail with "git failed with exit code 1".

Now build jobs checkout develop branch for dry runs while still using
the version tag for real releases.

Closes: GitHub Actions run #20464082726
2025-12-25 19:55:08 +01:00
Daniel Frey e74a3dffd2 fix: accept bug_fix workflow_type alias during planning (#240)
* fix(planning): accept bug_fix workflow_type alias

* style(planning): ruff format

* fix: refatored common logic

* fix: remove ruff errors

* fix: remove duplicate _normalize_workflow_type method

Remove the incorrectly placed duplicate method inside ContextLoader class.
The module-level function is the correct implementation being used.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: danielfrey63 <daniel.frey@sbb.ch>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
Co-authored-by: AndyMik90 <andre@mikalsenutvikling.no>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:53:55 +01:00
Daniel Frey 6ac8250b5b fix(paths): normalize relative paths to posix (#239)
Co-authored-by: danielfrey63 <daniel.frey@sbb.ch>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 19:53:33 +01:00
dependabot[bot] a2cee6941f chore(deps): bump @electron/rebuild in /apps/frontend (#271)
Bumps [@electron/rebuild](https://github.com/electron/rebuild) from 3.7.2 to 4.0.2.
- [Release notes](https://github.com/electron/rebuild/releases)
- [Commits](https://github.com/electron/rebuild/compare/v3.7.2...v4.0.2)

---
updated-dependencies:
- dependency-name: "@electron/rebuild"
  dependency-version: 4.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2025-12-25 19:46:00 +01:00
dependabot[bot] d4cad80a73 chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/frontend (#272)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.0.15 to 4.0.16.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-25 19:42:21 +01:00
Andy 596e95137b feat(github): add automated PR review with follow-up support (#252)
* feat(github): add GitHub automation system for issues and PRs

Implements comprehensive GitHub automation with three major components:

1. Issue Auto-Fix: Automatically creates specs from labeled issues
   - AutoFixButton component with progress tracking
   - useAutoFix hook for config and queue management
   - Backend handlers for spec creation from issues

2. GitHub PRs Tool: AI-powered PR review sidebar
   - New sidebar tab (Cmd+Shift+P) alongside GitHub Issues
   - PRList/PRDetail components for viewing PRs
   - Review system with findings by severity
   - Post review comments to GitHub

3. Issue Triage: Duplicate/spam/feature-creep detection
   - Triage handlers with label application
   - Configurable detection thresholds

Also adds:
- Debug logging (DEBUG=true) for all GitHub handlers
- Backend runners/github module with orchestrator
- AI prompts for PR review, triage, duplicate/spam detection
- dev:debug npm script for development with logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github-runner): resolve import errors for direct script execution

Changes runner.py and orchestrator.py to handle both:
- Package import: `from runners.github import ...`
- Direct script: `python runners/github/runner.py`

Uses try/except pattern for relative vs direct imports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github): correct argparse argument order for runner.py

Move --project global argument before subcommand so argparse can
correctly parse it. Fixes "unrecognized arguments: --project" error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* logs when debug mode is on

* refactor(github): extract service layer and fix linting errors

Major refactoring to improve maintainability and code quality:

Backend (Python):
- Extracted orchestrator.py (2,600 → 835 lines, 68% reduction) into 7 service modules:
  - prompt_manager.py: Prompt template management
  - response_parsers.py: AI response parsing
  - pr_review_engine.py: PR review orchestration
  - triage_engine.py: Issue triage logic
  - autofix_processor.py: Auto-fix workflow
  - batch_processor.py: Batch issue handling
- Fixed 18 ruff linting errors (F401, C405, C414, E741):
  - Removed unused imports (BatchValidationResult, AuditAction, locked_json_write)
  - Optimized collection literals (set([n]) → {n})
  - Removed unnecessary list() calls
  - Renamed ambiguous variable 'l' to 'label' throughout

Frontend (TypeScript):
- Refactored IPC handlers (19% overall reduction) with shared utilities:
  - autofix-handlers.ts: 1,042 → 818 lines
  - pr-handlers.ts: 648 → 543 lines
  - triage-handlers.ts: 437 lines (no duplication)
- Created utils layer: logger, ipc-communicator, project-middleware, subprocess-runner
- Split github-store.ts into focused stores: issues, pr-review, investigation, sync-status
- Split ReviewFindings.tsx into focused components

All imports verified, type checks passing, linting clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fixes during testing of PR

* feat(github): implement PR merge, assign, and comment features

- Add auto-assignment when clicking "Run AI Review"
- Implement PR merge functionality with squash method
- Add ability to post comments on PRs
- Display assignees in PR UI
- Add Approve and Merge buttons when review passes
- Update backend gh_client with pr_merge, pr_comment, pr_assign methods
- Create IPC handlers for new PR operations
- Update TypeScript interfaces and browser mocks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Improve PR review AI

* fix(github): use temp files for PR review posting to avoid shell escaping issues

When posting PR reviews with findings containing special characters (backticks,
parentheses, quotes), the shell command was interpreting them as commands instead
of literal text, causing syntax errors.

Changed both postPRReview and postPRComment handlers to write the body content
to temporary files and use gh CLI's --body-file flag instead of --body with
inline content. This safely handles ALL special characters without escaping issues.

Fixes shell errors when posting reviews with suggested fixes containing code snippets.

* fix(i18n): add missing GitHub PRs translation and document i18n requirements

Fixed missing translation key for GitHub PRs feature that was causing
"items.githubPRs" to display instead of the proper translated text.

Added comprehensive i18n guidelines to CLAUDE.md to ensure all future
frontend development follows the translation key pattern instead of
using hardcoded strings.

Also fixed missing deletePRReview mock function in browser-mock.ts
to resolve TypeScript compilation errors.

Changes:
- Added githubPRs translation to en/navigation.json
- Added githubPRs translation to fr/navigation.json
- Added Development Guidelines section to CLAUDE.md with i18n requirements
- Documented translation file locations and namespace usage patterns
- Added deletePRReview mock function to browser-mock.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix ui loading

* Github PR fixes

* improve claude.md

* lints/tests

* fix(github): handle PRs exceeding GitHub's 20K line diff limit

- Add PRTooLargeError exception for large PR detection
- Update pr_diff() to catch and raise PRTooLargeError for HTTP 406 errors
- Gracefully handle large PRs by skipping full diff and using individual file patches
- Add diff_truncated flag to PRContext to track when diff was skipped
- Large PRs will now review successfully using per-file diffs instead of failing

Fixes issue with PR #252 which has 100+ files exceeding the 20,000 line limit.

* fix: implement individual file patch fetching for large PRs

The PR review was getting stuck for large PRs (>20K lines) because when we
skipped the full diff due to GitHub API limits, we had no code to analyze.
The individual file patches were also empty, leaving the AI with just
file names and metadata.

Changes:
- Implemented _get_file_patch() to fetch individual patches via git diff
- Updated PR review engine to build composite diff from file patches when
  diff_truncated is True
- Added missing 'state' field to PRContext dataclass
- Limits composite diff to first 50 files for very large PRs
- Shows appropriate warnings when using reconstructed diffs

This allows AI review to proceed with actual code analysis even when the
full PR diff exceeds GitHub's limits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* 1min reduction

* docs: add GitHub Sponsors funding configuration

Enable the Sponsor button on the repository by adding FUNDING.yml
with the AndyMik90 GitHub Sponsors profile.

* feat(github-pr): add orchestrating agent for thorough PR reviews

Implement a new Opus 4.5 orchestrating agent that performs comprehensive
PR reviews regardless of size. Key changes:

- Add orchestrator_reviewer.py with strategic review workflow
- Add review_tools.py with subagent spawning capabilities
- Add pr_orchestrator.md prompt emphasizing thorough analysis
- Add pr_security_agent.md and pr_quality_agent.md subagent prompts
- Integrate orchestrator into pr_review_engine.py with config flag
- Fix critical bug where findings were extracted but not processed
  (indentation issue in _parse_orchestrator_output)

The orchestrator now correctly identifies issues in PRs that were
previously approved as "trivial". Testing showed 7 findings detected
vs 0 before the fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* i18n

* fix(github-pr): restrict pr_reviewer to read-only permissions

The PR review agent was using qa_reviewer agent type which has Bash
access, allowing it to checkout branches and make changes during
review. Created new pr_reviewer agent type with BASE_READ_TOOLS only
(no Bash, no writes, no auto-claude tools).

This prevents the PR review from accidentally modifying code or
switching branches during analysis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github-pr): robust category mapping and JSON parsing for PR review

The orchestrator PR review was failing to extract findings because:

1. AI generates category names like 'correctness', 'consistency', 'testing'
   that aren't in our ReviewCategory enum - added flexible mapping

2. JSON sometimes embedded in markdown code blocks (```json) which broke
   parsing - added code block extraction as first parsing attempt

Changes:
- Add _CATEGORY_MAPPING dict to map AI categories to valid enum values
- Add _map_category() helper function with fallback to QUALITY
- Add severity parsing with fallback to MEDIUM
- Add markdown code block detection (```json) before raw JSON parsing
- Add _extract_findings_from_data() helper to reduce code duplication
- Apply same fixes to review_tools.py for subagent parsing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pr-review): improve post findings UX with batch support and feedback

- Fix post findings failing on own PRs by falling back from REQUEST_CHANGES
  to COMMENT when GitHub returns 422 error
- Change status badge to show "Reviewed" instead of "Commented" until
  findings are actually posted to GitHub
- Add success notification when findings are posted (auto-dismisses after 3s)
- Add batch posting support: track posted findings, show "Posted" badge,
  allow posting remaining findings in additional batches
- Show loading state on button while posting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github): resolve stale timestamp and null author bugs

- Fix stale timestamp in batch_issues.py: Move updated_at assignment
  BEFORE to_dict() serialization so the saved JSON contains the correct
  timestamp instead of the old value

- Fix AttributeError in context_gatherer.py: Handle null author/user
  fields when GitHub API returns null for deleted/suspended users
  instead of an empty object

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address all high and medium severity PR review findings

HIGH severity fixes:
- Command Injection in autofix-handlers.ts: Use execFileSync with args array
- Command Injection in pr-handlers.ts (3 locations): Use execFileSync + validation
- Command Injection in triage-handlers.ts: Use execFileSync + label validation
- Token Exposure in bot_detection.py: Pass token via GH_TOKEN env var

MEDIUM severity fixes:
- Environment variable leakage in subprocess-runner.ts: Filter to safe vars only
- Debug logging in subprocess-runner.ts: Only log in development mode
- Delimiter escape bypass in sanitize.py: Use regex pattern for variations
- Insecure file permissions in trust.py: Use os.open with 0o600 mode
- No file locking in learning.py: Use FileLock + atomic_write utilities
- Bare except in confidence.py: Log error with specific exception info
- Fragile module import in pr_review_engine.py: Import at module level
- State transition validation in models.py: Enforce can_transition_to()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* PR followup

* fix(security): add usedforsecurity=False to MD5 hash calls

MD5 is used for generating unique IDs/cache keys, not for security purposes.
Adding usedforsecurity=False resolves Bandit B324 warnings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address all high-priority PR review findings

Fixes 5 high-priority issues from Auto Claude PR Review:

1. orchestrator_reviewer.py: Token budget tracking now increments
   total_tokens from API response usage data

2. pr_review_engine.py: Async exceptions now re-raise RuntimeError
   instead of silently returning empty results

3. batch_issues.py: IssueBatch.save() now uses locked_json_write
   for atomic file operations with file locking

4. project-middleware.ts: Added validateProjectPath() to prevent
   path traversal attacks (checks absolute, no .., exists, is dir)

5. orchestrator.py: Exception handling now logs full traceback and
   preserves exception type/context in error messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address all high-priority PR review findings

Fixes 5 high-priority issues from Auto Claude PR Review:

1. orchestrator_reviewer.py: Token budget tracking now increments
   total_tokens from API response usage data

2. pr_review_engine.py: Async exceptions now re-raise RuntimeError
   instead of silently returning empty results

3. batch_issues.py: IssueBatch.save() now uses locked_json_write
   for atomic file operations with file locking

4. project-middleware.ts: Added validateProjectPath() to prevent
   path traversal attacks (checks absolute, no .., exists, is dir)

5. orchestrator.py: Exception handling now logs full traceback and
   preserves exception type/context in error messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(ui): add PR status labels to list view

Add secondary status badges to the PR list showing review state at a glance:
- "Changes Requested" (warning) - PRs with blocking issues (critical/high)
- "Ready to Merge" (green) - PRs with only non-blocking suggestions
- "Ready for Follow-up" (blue) - PRs with new commits since last review

The "Ready for Follow-up" badge uses a cached new commits check from the
store, only shown after the detail view confirms new commits via SHA
comparison. This prevents false positives from PR updatedAt timestamp
changes (which can happen from comments, labels, etc).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* PR labels

* auto-claude: Initialize subtask-based implementation plan

- Workflow type: feature
- Phases: 3
- Subtasks: 6
- Ready for autonomous implementation

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:29:04 +01:00
Alex d42041c5b5 ci: implement enterprise-grade PR quality gates and security scanning (#266)
* ci: implement enterprise-grade PR quality gates and security scanning

* ci: implement enterprise-grade PR quality gates and security scanning

* fix:pr comments and improve code

* fix: improve commit linting and code quality

* Removed the dependency-review job (i added it)

* fix: address CodeRabbit review comments

- Expand scope pattern to allow uppercase, underscores, slashes, dots
- Add concurrency control to cancel duplicate security scan runs
- Add explanatory comment for Bandit CLI flags
- Remove dependency-review job (requires repo settings)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update commit lint examples with expanded scope patterns

Show slashes and dots in scope examples to demonstrate
the newly allowed characters (api/users, package.json)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove feature request issue template

Feature requests are directed to GitHub Discussions
via the issue template config.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address security vulnerabilities in service orchestrator

- Fix port parsing crash on malformed docker-compose entries
- Fix shell injection risk by using shlex.split() with shell=False

Prevents crashes when docker-compose.yml contains environment
variables in port mappings (e.g., '${PORT}:8080') and eliminates
shell injection vulnerabilities in subprocess execution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 15:51:55 +01:00
delyethan a3f87540c6 fix: update path resolution for ollama_model_detector.py in memory handlers (#263) 2025-12-25 09:54:31 +01:00
Mitsu f843811292 feat: add i18n internationalization system (#248)
* Add multilingual support and i18n integration

- Implemented i18n framework using `react-i18next` for translation management.
- Added support for English and French languages with translation files.
- Integrated language selector into settings.
- Updated all text strings in UI components to use translation keys.
- Ensured smooth language switching with live updates.

* Migrate remaining hard-coded strings to i18n system

- TaskCard: status labels, review reasons, badges, action buttons
- PhaseProgressIndicator: execution phases, progress labels
- KanbanBoard: drop zone, show archived, tooltips
- CustomModelModal: dialog title, description, labels
- ProactiveSwapListener: account switch notifications
- AgentProfileSelector: phase labels, custom configuration
- GeneralSettings: agent framework option

Added translation keys for en/fr locales in tasks.json, common.json,
and settings.json for complete i18n coverage.

* Add i18n support to dialogs and settings components

- AddFeatureDialog: form labels, validation messages, buttons
- AddProjectModal: dialog steps, form fields, actions
- RateLimitIndicator: rate limit notifications
- RateLimitModal: account switching, upgrade prompts
- AdvancedSettings: updates and notifications sections
- ThemeSettings: theme selection labels
- Updated dialogs.json locales (en/fr)

* Fix truncated 'ready' message in dialogs locales

* Fix backlog terminology in i18n locales

Change "Planning"/"Planification" to standard PM term "Backlog"

* Migrate settings navigation and integration labels to i18n

- AppSettings: nav items, section titles, buttons
- IntegrationSettings: Claude accounts, auto-switch, API keys labels
- Added settings nav/projectSections/integrations translation keys
- Added buttons.saving to common translations

* Migrate AgentProfileSettings and Sidebar init dialog to i18n

- AgentProfileSettings: migrate phase config labels, section title,
  description, and all hardcoded strings to settings namespace
- Sidebar: migrate init dialog strings to dialogs namespace with
  common buttons from common namespace
- Add new translation keys for agent profile settings and update dialog

* Migrate AppSettings navigation labels to i18n

- Add useTranslation hook to AppSettings.tsx
- Replace hardcoded section labels with dynamic translations
- Add projectSections translations for project settings nav
- Add rerunWizardDescription translation key

* Add explicit typing to notificationItems array

Import NotificationSettings type and use keyof to properly type
the notification item keys, removing manual type assertion.
2025-12-24 17:37:31 +01:00
Andy 5e8c53080f Revert "Feat/Auto Fix Github issues and do extensive AI PR reviews (#250)" (#251)
This reverts commit 348de6dfe7.
2025-12-24 17:02:47 +01:00
Andy 348de6dfe7 Feat/Auto Fix Github issues and do extensive AI PR reviews (#250)
* feat(github): add GitHub automation system for issues and PRs

Implements comprehensive GitHub automation with three major components:

1. Issue Auto-Fix: Automatically creates specs from labeled issues
   - AutoFixButton component with progress tracking
   - useAutoFix hook for config and queue management
   - Backend handlers for spec creation from issues

2. GitHub PRs Tool: AI-powered PR review sidebar
   - New sidebar tab (Cmd+Shift+P) alongside GitHub Issues
   - PRList/PRDetail components for viewing PRs
   - Review system with findings by severity
   - Post review comments to GitHub

3. Issue Triage: Duplicate/spam/feature-creep detection
   - Triage handlers with label application
   - Configurable detection thresholds

Also adds:
- Debug logging (DEBUG=true) for all GitHub handlers
- Backend runners/github module with orchestrator
- AI prompts for PR review, triage, duplicate/spam detection
- dev:debug npm script for development with logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github-runner): resolve import errors for direct script execution

Changes runner.py and orchestrator.py to handle both:
- Package import: `from runners.github import ...`
- Direct script: `python runners/github/runner.py`

Uses try/except pattern for relative vs direct imports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(github): correct argparse argument order for runner.py

Move --project global argument before subcommand so argparse can
correctly parse it. Fixes "unrecognized arguments: --project" error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* logs when debug mode is on

* refactor(github): extract service layer and fix linting errors

Major refactoring to improve maintainability and code quality:

Backend (Python):
- Extracted orchestrator.py (2,600 → 835 lines, 68% reduction) into 7 service modules:
  - prompt_manager.py: Prompt template management
  - response_parsers.py: AI response parsing
  - pr_review_engine.py: PR review orchestration
  - triage_engine.py: Issue triage logic
  - autofix_processor.py: Auto-fix workflow
  - batch_processor.py: Batch issue handling
- Fixed 18 ruff linting errors (F401, C405, C414, E741):
  - Removed unused imports (BatchValidationResult, AuditAction, locked_json_write)
  - Optimized collection literals (set([n]) → {n})
  - Removed unnecessary list() calls
  - Renamed ambiguous variable 'l' to 'label' throughout

Frontend (TypeScript):
- Refactored IPC handlers (19% overall reduction) with shared utilities:
  - autofix-handlers.ts: 1,042 → 818 lines
  - pr-handlers.ts: 648 → 543 lines
  - triage-handlers.ts: 437 lines (no duplication)
- Created utils layer: logger, ipc-communicator, project-middleware, subprocess-runner
- Split github-store.ts into focused stores: issues, pr-review, investigation, sync-status
- Split ReviewFindings.tsx into focused components

All imports verified, type checks passing, linting clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:43:20 +01:00
HSSAINI Saad 0f7d6e0530 fix: resolve Python detection and backend packaging issues (#241)
* fix: resolve Python detection and backend packaging issues

- Fix backend packaging path (auto-claude -> backend) to match path-resolver.ts expectations
- Add future annotations import to config_parser.py for Python 3.9+ compatibility
- Use findPythonCommand() in project-context-handlers to prioritize Homebrew Python
- Improve Python detection to prefer Homebrew paths over system Python on macOS

This resolves the following issues:
- 'analyzer.py not found' error due to incorrect packaging destination
- TypeError with 'dict | None' syntax on Python < 3.10
- Wrong Python interpreter being used (system Python instead of Homebrew Python 3.10+)

Tested on macOS with packaged app - project index now loads successfully.

* refactor: address PR review feedback

- Extract findHomebrewPython() helper to eliminate code duplication between
  findPythonCommand() and getDefaultPythonCommand()
- Remove hardcoded version-specific paths (python3.12) and rely only on
  generic Homebrew symlinks for better maintainability
- Remove unnecessary 'from __future__ import annotations' from config_parser.py
  since backend requires Python 3.12+ where union types are native

These changes make the code more maintainable, less fragile to Python version
changes, and properly reflect the project's Python 3.12+ requirement.
2025-12-24 14:03:49 +01:00
Joris Slagter 5ccdb6abc5 fix: add future annotations import to discovery.py (#229)
Adds 'from __future__ import annotations' to spec/discovery.py for
Python 3.9+ compatibility with type hints.

This completes the Python compatibility fixes that were partially
applied in previous commits. All 26 analysis and spec Python files
now have the future annotations import.

Related: #128

Co-authored-by: Joris Slagter <mail@jorisslagter.nl>
2025-12-24 07:12:10 +01:00
souky-byte 6ec8549f63 Fix/ideation status sync (#212)
* fix(ideation): add missing event forwarders for status sync

- Add event forwarders in ideation-handlers.ts for progress, log,
  type-complete, type-failed, complete, error, and stopped events
- Fix ideation-type-complete to load actual ideas array from JSON files
  instead of emitting only the count

Resolves UI getting stuck at 0/3 complete during ideation generation.

* fix(ideation): fix UI not updating after actions

- Fix getIdeationSummary to count only active ideas (exclude dismissed/archived)
  This ensures header stats match the visible ideas count
- Add transformSessionFromSnakeCase to properly transform session data
  from backend snake_case to frontend camelCase on ideation-complete event
- Transform raw session before emitting ideation-complete event

Resolves header showing stale counts after dismissing/deleting ideas.

* fix(ideation): improve type safety and async handling in ideation type completion

- Replace synchronous readFileSync with async fsPromises.readFile in ideation-type-complete handler
- Wrap async file read in IIFE with proper error handling to prevent unhandled promise rejections
- Add type validation for IdeationType with VALID_IDEATION_TYPES set and isValidIdeationType guard
- Add validateEnabledTypes function to filter out invalid type values and log dropped entries
- Handle ENOENT separately

* fix(ideation): improve generation state management and error handling

- Add explicit isGenerating flag to prevent race conditions during async operations
- Implement 5-minute timeout for generation with automatic cleanup and error state
- Add ideation-stopped event emission when process is intentionally killed
- Replace console.warn/error with proper ideation-error events in agent-queue
- Add resetGeneratingTypes helper to transition all generating types to a target state
- Filter out dismissed/

* refactor(ideation): improve event listener cleanup and timeout management

- Extract event handler functions in ideation-handlers.ts to enable proper cleanup
- Return cleanup function from registerIdeationHandlers to remove all listeners
- Replace single generationTimeoutId with Map to support multiple concurrent projects
- Add clearGenerationTimeout helper to centralize timeout cleanup logic
- Extract loadIdeationType IIFE to named function for better error context
- Enhance error logging with projectId,

* refactor: use async file read for ideation and roadmap session loading

- Replace synchronous readFileSync with async fsPromises.readFile
- Prevents blocking the event loop during file operations
- Consistent with async pattern used elsewhere in the codebase
- Improved error handling with proper event emission

* fix(agent-queue): improve roadmap completion handling and error reporting

- Add transformRoadmapFromSnakeCase to convert backend snake_case to frontend camelCase
- Transform raw roadmap data before emitting roadmap-complete event
- Add roadmap-error emission for unexpected errors during completion
- Add roadmap-error emission when project path is unavailable
- Remove duplicate ideation-type-complete emission from error handler (event already emitted in loadIdeationType)
- Update error log message
2025-12-23 18:02:45 +01:00
Andy 53527293cd fix(core): add global spec numbering lock to prevent collisions (#209)
Implements distributed file-based locking for spec number coordination
across main project and all worktrees. Previously, parallel spec creation
could assign the same number to different specs (e.g., 042-bmad-task and
042-gitlab-integration both using number 042).

The fix adds SpecNumberLock class that:
- Acquires exclusive lock before calculating spec numbers
- Scans ALL locations (main project + worktrees) for global maximum
- Creates spec directories atomically within the lock
- Handles stale locks via PID-based detection with 30s timeout

Applied to both Python backend (spec_runner.py flow) and TypeScript
frontend (ideation conversion, GitHub/GitLab issue import).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 18:00:55 +01:00
Fernando Possebon 02bef954f3 feat: Add OpenRouter as LLM/embedding provider (#162)
* feat: Add OpenRouter as LLM/embedding provider

Add OpenRouter provider support for Graphiti memory integration,
enabling access to multiple LLM providers through a single API.

Changes:
Backend:
- Created openrouter_llm.py: OpenRouter LLM provider using OpenAI-compatible API
- Created openrouter_embedder.py: OpenRouter embedder provider
- Updated config.py: Added OpenRouter to provider enums and configuration
  - New fields: openrouter_api_key, openrouter_base_url, openrouter_llm_model, openrouter_embedding_model
  - Validation methods updated for OpenRouter
- Updated factory.py: Added OpenRouter to LLM and embedder factories
- Updated provider __init__.py files: Exported new OpenRouter functions

Frontend:
- Updated project.ts types: Added 'openrouter' to provider type unions
  - GraphitiProviderConfig extended with OpenRouter fields
- Updated GraphitiStep.tsx: Added OpenRouter to provider arrays
  - LLM_PROVIDERS: 'Multi-provider aggregator'
  - EMBEDDING_PROVIDERS: 'OpenAI-compatible embeddings'
  - Added OpenRouter API key input field with show/hide toggle
  - Link to https://openrouter.ai/keys
- Updated env-handlers.ts: OpenRouter .env generation and parsing
  - Template generation for OPENROUTER_* variables
  - Parsing from .env files with proper type casting

Documentation:
- Updated .env.example with OpenRouter section
  - Configuration examples
  - Popular model recommendations
  - Example configuration (#6)

Fixes #92

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: address CodeRabbit review comments for OpenRouter

- Add globalOpenRouterApiKey to settings types and store updates
- Initialize openrouterApiKey from global settings
- Update documentation to include OpenRouter in provider lists
- Add OpenRouter handling to get_embedding_dimension() method
- Add openrouter to provider cleanup list
- Add OpenRouter to get_available_providers() function
- Clarify Legacy comment for openrouterLlmModel

These changes complete the OpenRouter integration by ensuring proper
settings persistence and provider detection across the application.

* fix: apply ruff formatting to OpenRouter code

- Break long error message across multiple lines
- Format provider list with one item per line
- Fixes lint CI failure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 17:58:55 +01:00
Fernando Possebon f168bdc3ac fix: Add Python 3.10+ version validation and GitHub Actions Python setup (#180 #167) (#208)
This fixes critical bug where macOS users with default Python 3.9.6 couldn't use Auto-Claude because claude-agent-sdk requires Python 3.10+.

Root Cause:
- Auto-Claude doesn't bundle Python, relies on system Python
- python-detector.ts accepted any Python 3.x without checking minimum version
- macOS ships with Python 3.9.6 by default (incompatible)
- GitHub Actions runners didn't explicitly set Python version

Changes:
1. python-detector.ts:
   - Added getPythonVersion() to extract version from command
   - Added validatePythonVersion() to check if >= 3.10.0
   - Updated findPythonCommand() to skip Python < 3.10 with clear error messages

2. python-env-manager.ts:
   - Import and use findPythonCommand() (already has version validation)
   - Simplified findSystemPython() to use shared validation logic
   - Updated error message from "Python 3.9+" to "Python 3.10+" with download link

3. .github/workflows/release.yml:
   - Added Python 3.11 setup to all 4 build jobs (macOS Intel, macOS ARM64, Windows, Linux)
   - Ensures consistent Python version across all platforms during build

Impact:
- macOS users with Python 3.9 now see clear error with download link
- macOS users with Python 3.10+ work normally
- CI/CD builds use consistent Python 3.11
- Prevents "ModuleNotFoundError: dotenv" and dependency install failures

Fixes #180, #167

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 16:34:02 +01:00
Andy e3eec68aab fix(ci): correct welcome workflow PR message (#206)
- Change branch reference from main to develop
- Fix contribution guide link to use full URL
- Remove hyphen from "Auto Claude" in welcome message
2025-12-23 15:58:59 +01:00
Andy 407a0bee5e Feat/beta release (#193)
* chore: update README version to 2.7.1

Updated the version badge and download links in the README to reflect the new release version 2.7.1, ensuring users have the correct information for downloading the latest builds.

* feat(releases): add beta release system with user opt-in

Implements a complete beta release workflow that allows users to opt-in
to receiving pre-release versions. This enables testing new features
before they're included in stable releases.

Changes:
- Add beta-release.yml workflow for creating beta releases from develop
- Add betaUpdates setting with UI toggle in Settings > Updates
- Add update channel support to electron-updater (beta vs latest)
- Extract shared settings-utils.ts to reduce code duplication
- Add prepare-release.yml workflow for automated release preparation
- Document beta release process in CONTRIBUTING.md and RELEASE.md

Users can enable beta updates in Settings > Updates, and maintainers
can trigger beta releases via the GitHub Actions workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* workflow update

* ci(github): update Discord link and redirect feature requests to discussions

Update Discord invite link to correct URL (QhRnz9m5HE) across all GitHub
templates and workflows. Redirect feature requests from issue template
to GitHub Discussions for better community engagement.

Changes:
- config.yml: Add feature request link to Discussions, fix Discord URL
- question.yml: Update Discord link in pre-question guidance
- welcome.yml: Update Discord link in first-time contributor message

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 15:20:09 +01:00
Andy 8f766ad16e feat/beta-release (#190)
* chore: update README version to 2.7.1

Updated the version badge and download links in the README to reflect the new release version 2.7.1, ensuring users have the correct information for downloading the latest builds.

* feat(releases): add beta release system with user opt-in

Implements a complete beta release workflow that allows users to opt-in
to receiving pre-release versions. This enables testing new features
before they're included in stable releases.

Changes:
- Add beta-release.yml workflow for creating beta releases from develop
- Add betaUpdates setting with UI toggle in Settings > Updates
- Add update channel support to electron-updater (beta vs latest)
- Extract shared settings-utils.ts to reduce code duplication
- Add prepare-release.yml workflow for automated release preparation
- Document beta release process in CONTRIBUTING.md and RELEASE.md

Users can enable beta updates in Settings > Updates, and maintainers
can trigger beta releases via the GitHub Actions workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* workflow update

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:28:09 +01:00
Andy ced2ad479f fix/PRs from old main setup to apps structure (#185)
* fix(core): add task persistence, terminal handling, and HTTP 300 fixes

Consolidated bug fixes from PRs #168, #170, #171:

- Task persistence (#168): Scan worktrees for tasks on app restart
  to prevent loss of in-progress work and wasted API credits. Tasks
  in .worktrees/*/specs are now loaded and deduplicated with main.

- Terminal buttons (#170): Fix "Open Terminal" buttons silently
  failing on macOS by properly awaiting createTerminal() Promise.
  Added useTerminalHandler hook with loading states and error display.

- HTTP 300 errors (#171): Handle branch/tag name collisions that
  cause update failures. Added validation script to prevent conflicts
  before releases and user-friendly error messages with manual
  download links.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(platform): add path resolution, spaces handling, and XDG support

This commit consolidates multiple bug fixes from community PRs:

- PR #187: Path resolution fix - Update path detection to find apps/backend
  instead of legacy auto-claude directory after v2.7.2 restructure

- PR #182/#155: Python path spaces fix - Improve parsePythonCommand() to
  handle quoted paths and paths containing spaces without splitting

- PR #161: Ollama detection fix - Add new apps structure paths for
  ollama_model_detector.py script discovery

- PR #160: AppImage support - Add XDG Base Directory compliant paths for
  Linux sandboxed environments (AppImage, Flatpak, Snap). New files:
  - config-paths.ts: XDG path utilities
  - fs-utils.ts: Filesystem utilities with fallback support

- PR #159: gh CLI PATH fix - Add getAugmentedEnv() utility to include
  common binary locations (Homebrew, snap, local) in PATH for child
  processes. Fixes gh CLI not found when app launched from Finder/Dock.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit/Cursor review comments on PR #185

Fixes from code review:
- http-client.ts: Use GITHUB_CONFIG instead of hardcoded owner in HTTP 300 error message
- validate-release.js: Fix substring matching bug in branch detection that could cause false positives (e.g., v2.7 matching v2.7.2)
- bump-version.js: Remove unnecessary try-catch wrapper (exec() already exits on failure)
- execution-handlers.ts: Capture original subtask status before mutation for accurate logging
- fs-utils.ts: Add error handling to safeWriteFile with proper logging

Dismissed as trivial/not applicable:
- config-paths.ts: Exhaustive switch check (over-engineering)
- env-utils.ts: PATH priority documentation (existing comments sufficient)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address additional CodeRabbit review comments (round 2)

Fixes from second round of code review:
- fs-utils.ts: Wrap test file cleanup in try-catch for Windows file locking
- fs-utils.ts: Add error handling to safeReadFile for consistency with safeWriteFile
- http-client.ts: Use GITHUB_CONFIG in fetchJson (missed in first round)
- validate-release.js: Exclude symbolic refs (origin/HEAD -> origin/main) from branch check
- python-detector.ts: Return cleanPath instead of pythonPath for empty input edge case

Dismissed as trivial/not applicable:
- execution-handlers.ts: Redundant checkSubtasksCompletion call (micro-optimization)

🤖 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>
2025-12-23 13:33:11 +01:00
Andy 05f5d3038b fix: hide status badge when execution phase badge is showing (#154)
* fix: analyzer Python compatibility and settings integration

Fixes project index analyzer failing with TypeError on Python type hints.

Changes:
- Added 'from __future__ import annotations' to all analysis modules
- Fixed project discovery to support new analyzer JSON format
- Read Python path directly from settings.json instead of pythonEnvManager
- Added stderr/stdout logging for analyzer debugging

Resolves 'Discovered 0 files' and 'TypeError: unsupported operand type' issues.

* auto-claude: subtask-1-1 - Hide status badge when execution phase badge is showing

When a task has an active execution (planning, coding, etc.), the
execution phase badge already displays the correct state with a spinner.
The status badge was also rendering, causing duplicate/confusing badges
(e.g., both "Planning" and "Pending" showing at the same time).

This fix wraps the status badge in a conditional that only renders when
there's no active execution, eliminating the redundant badge display.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ipc): remove unused pythonEnvManager parameter and fix ES6 import

Address CodeRabbit review feedback:
- Remove unused pythonEnvManager parameter from registerProjectContextHandlers
  and registerContextHandlers (the code reads Python path directly from
  settings.json instead)
- Replace require('electron').app with proper ES6 import for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(lint): fix import sorting in analysis module

Run ruff --fix to resolve I001 lint errors after merging develop.
All 23 files in apps/backend/analysis/ now have properly sorted imports.

---------

Co-authored-by: Joris Slagter <mail@jorisslagter.nl>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:31:44 +01:00
Enes Cingöz 6951251b33 feat: Add UI scale feature with 75-200% range (#125)
* feat: add UI scale feature

* refactor: extract UI scale bounds to shared constants

* fix: duplicated import
2025-12-23 12:30:32 +01:00
AndyMik90 30e7536b63 fix(task): stop running process when task status changes away from in_progress
When a user drags a running task back to Planning (or any other column),
the process was not being stopped, leaving a "ghost" process that
prevented deletion with "Cannot delete a running task" error.

Now the task process is automatically killed when status changes away
from in_progress, ensuring the process state stays in sync with the UI.
2025-12-23 00:11:48 +01:00
Andy 220faf0fb4 Fix/linear 400 error
* fix: Linear API authentication and GraphQL types

- Remove Bearer prefix from Authorization header (Linear API keys are sent directly)
- Change GraphQL variable types from String! to ID! for teamId and issue IDs
- Improve error handling to show detailed Linear API error messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Radix Select empty value error in Linear import modal

Use '__all__' sentinel value instead of empty string for "All projects"
option, as Radix Select does not allow empty string values.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add CodeRabbit configuration file

Introduce a new .coderabbit.yaml file to configure CodeRabbit settings, including review profiles, automatic review options, path filters, and specific instructions for different file types. This enhances the code review process by providing tailored guidelines for Python, TypeScript, and test files.

* fix: correct GraphQL types for Linear team queries

Linear API uses different types for different queries:
- team(id:) expects String!
- issues(filter: { team: { id: { eq: } } }) expects ID!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: refresh task list after Linear import

Call loadTasks() after successful Linear import to update the kanban
board without requiring a page reload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* cleanup

* cleanup

* fix: address CodeRabbit review comments for Linear integration

- Fix unsafe JSON parsing: check response.ok before parsing JSON to handle
  non-JSON error responses (e.g., 503 from proxy) gracefully
- Use ID! type instead of String! for teamId in LINEAR_GET_PROJECTS query
  for GraphQL type consistency
- Remove debug console.log (ESLint config only allows warn/error)
- Refresh task list on partial import success (imported > 0) instead of
  requiring full success
- Fix pre-existing TypeScript and lint issues blocking commit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* version sync logic

* lints for develop branch

* chore: update CI workflow to include develop branch

- Modified the CI configuration to trigger on pushes and pull requests to both main and develop branches, enhancing the workflow for development and integration processes.

* fix: update project directory auto-detection for apps/backend structure

The project directory auto-detection was checking for the old `auto-claude/`
directory name but needed to check for `apps/backend/`. When running from
`apps/backend/`, the directory name is `backend` not `auto-claude`, so the
check would fail and `project_dir` would incorrectly remain as `apps/backend/`
instead of resolving to the project root (2 levels up).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use GraphQL variables instead of string interpolation in LINEAR_GET_ISSUES

Replace direct string interpolation of teamId and linearProjectId with
proper GraphQL variables. This prevents potential query syntax errors if
IDs contain special characters like double quotes, and aligns with the
variable-based approach used elsewhere in the file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): correct logging level and await loadTasks on import complete

- Change console.warn to console.log for import success messages
  (warn is incorrect severity for normal completion)
- Make onImportComplete callback async and await loadTasks()
  to prevent potential unhandled promise rejections

Applies CodeRabbit review feedback across 3 LinearTaskImportModal usages.

* fix(hooks): use POSIX-compliant find instead of bash glob

The pre-commit hook uses #!/bin/sh but had bash-specific ** glob
pattern for staging ruff-formatted files. The ** pattern only works
in bash with globstar enabled - in POSIX sh it expands literally
and won't match subdirectories, causing formatted files in nested
directories to not be staged.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 00:01:19 +01:00
Joris Slagter f96c6301f4 fix: remove legacy path from auto-claude source detection (#148)
Removes the legacy 'auto-claude' path from the possiblePaths array
in agent-process.ts. This path was from before the monorepo
restructure (v2.7.2) and is no longer needed.

The legacy path was causing spec_runner.py to be looked up at the
wrong location:
- OLD (wrong): /path/to/auto-claude/auto-claude/runners/spec_runner.py
- NEW (correct): /path/to/apps/backend/runners/spec_runner.py

This aligns with the new monorepo structure where all backend code
lives in apps/backend/.

Fixes #147

Co-authored-by: Joris Slagter <mail@jorisslagter.nl>
2025-12-22 22:59:48 +01:00
Joris Slagter ebd8340d82 fix: resolve Python environment race condition (#142)
Implemented promise queue pattern in PythonEnvManager to handle
concurrent initialization requests. Previously, multiple simultaneous
requests (e.g., startup + merge) would fail with "Already
initializing" error.

Also fixed parsePythonCommand() to handle file paths with spaces by
checking file existence before splitting on whitespace.

Changes:
- Added initializationPromise field to queue concurrent requests
- Split initialize() into public and private _doInitialize()
- Enhanced parsePythonCommand() with existsSync() check

Co-authored-by: Joris Slagter <mail@jorisslagter.nl>
2025-12-22 22:50:56 +01:00
rayBlock df779530e7 Feat: Ollama download progress tracking with new apps structure (#141)
* feat(ollama): add real-time download progress tracking for model downloads

Implement comprehensive download progress tracking with:
- NDJSON parsing for streaming progress data from Ollama API
- Real-time speed calculation (MB/s, KB/s, B/s) with useRef for delta tracking
- Time remaining estimation based on download speed
- Animated progress bars in OllamaModelSelector component
- IPC event streaming from main process to renderer
- Proper listener management with cleanup functions

Changes:
- memory-handlers.ts: Parse NDJSON from Ollama stderr, emit progress events
- OllamaModelSelector.tsx: Display progress bars with speed and time remaining
- project-api.ts: Implement onDownloadProgress listener with cleanup
- ipc.ts types: Define onDownloadProgress listener interface
- infrastructure-mock.ts: Add mock implementation for browser testing

This allows users to see real-time feedback when downloading Ollama models,
including percentage complete, current download speed, and estimated time remaining.

* test: add focused test coverage for Ollama download progress feature

Add unit tests for the critical paths of the real-time download progress tracking:

- Progress calculation tests (52 tests): Speed/time/percentage calculations with comprehensive edge case coverage (zero speeds, NaN, Infinity, large numbers)
- NDJSON parser tests (33 tests): Streaming JSON parsing from Ollama, buffer management for incomplete lines, error handling

All 562 unit tests passing with clean dependencies. Tests focus on critical mathematical logic and data processing - the most important paths that need verification.

Test coverage:
 Speed calculation and formatting (B/s, KB/s, MB/s)
 Time remaining calculations (seconds, minutes, hours)
 Percentage clamping (0-100%)
 NDJSON streaming with partial line buffering
 Invalid JSON handling
 Real Ollama API responses
 Multi-chunk streaming scenarios

* docs: add comprehensive JSDoc docstrings for Ollama download progress feature

- Enhanced OllamaModelSelector component with detailed JSDoc
  * Documented component props, behavior, and usage examples
  * Added docstrings to internal functions (checkInstalledModels, handleDownload, handleSelect)
  * Explained progress tracking algorithm and useRef usage

- Improved memory-handlers.ts documentation
  * Added docstring to main registerMemoryHandlers function
  * Documented all Ollama-related IPC handlers (check-status, list-embedding-models, pull-model)
  * Added JSDoc to executeOllamaDetector helper function
  * Documented interface types (OllamaStatus, OllamaModel, OllamaEmbeddingModel, OllamaPullResult)
  * Explained NDJSON parsing and progress event structure

- Enhanced test file documentation
  * Added docstrings to NDJSON parser test utilities with algorithm explanation
  * Documented all calculation functions (speed, time, percentage)
  * Added detailed comments on formatting and bounds-checking logic

- Improved overall code maintainability
  * Docstring coverage now meets 80%+ threshold for code review
  * Clear explanation of progress tracking implementation details
  * Better context for future maintainers working with download streaming

* feat: add batch task creation and management CLI commands

- Handle batch task creation from JSON files
- Show status of all specs in project
- Cleanup tool for completed specs
- Full integration with new apps/backend structure
- Compatible with implementation_plan.json workflow

* test: add batch task test file and testing checklist

- batch_test.json: Sample tasks for testing batch creation
- TESTING_CHECKLIST.md: Comprehensive testing guide for Ollama and batch tasks
- Includes UI testing steps, CLI testing steps, and edge cases
- Ready for manual and automated testing

* chore: update package-lock.json to match v2.7.2

* test: update checklist with verification results and architecture validation

* docs: add comprehensive implementation summary for Ollama + Batch features

* docs: add comprehensive Phase 2 testing guide with checklists and procedures

* docs: add NEXT_STEPS guide for Phase 2 testing

* fix: resolve merge conflict in project-api.ts from Ollama feature cherry-pick

* fix: remove duplicate Ollama check status handler registration

* test: update checklist with Phase 2 bug findings and fixes

---------

Co-authored-by: ray <ray@rays-MacBook-Pro.local>
2025-12-22 22:40:20 +01:00
Andy 0adaddacaa Feature/apps restructure v2.7.2 (#138)
* refactor: restructure project to Apps/frontend and Apps/backend

- Move auto-claude-ui to Apps/frontend with feature-based architecture
- Move auto-claude to Apps/backend
- Switch from pnpm to npm for frontend
- Update Node.js requirement to v24.12.0 LTS
- Add pre-commit hooks for lint, typecheck, and security audit
- Add commit-msg hook for conventional commits
- Fix CommonJS compatibility issues (postcss.config, postinstall scripts)
- Update README with comprehensive setup and contribution guidelines
- Configure ESLint to ignore .cjs files
- 0 npm vulnerabilities

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* feat(refactor): clean code and move to npm

* feat(refactor): clean code and move to npm

* chore: update to v2.7.0, remove Docker deps (LadybugDB is embedded)

* feat: v2.8.0 - update workflows and configs for Apps/ structure, npm

* fix: resolve Python lint errors (F401, I001)

* fix: update test paths for Apps/backend structure

* fix: add missing facade files and update paths for Apps/backend structure

- Fix ruff lint error I001 in auto_claude_tools.py
- Create missing facade files to match upstream (agent, ci_discovery, critique, etc.)
- Update test paths from auto-claude/ to Apps/backend/
- Update .pre-commit-config.yaml paths for Apps/ structure
- Add pytest to pre-commit hooks (skip slow/integration/Windows-incompatible tests)
- Fix Unicode encoding in test_agent_architecture.py for Windows

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* feat: improve readme

* fix: new path

* fix: correct release workflow and docs for Apps/ restructure

- Fix ARM64 macOS build: pnpm → npm, auto-claude-ui → Apps/frontend
- Fix artifact upload paths in release.yml
- Update Node.js version to 24 for consistency
- Update CLI-USAGE.md with Apps/backend paths
- Update RELEASE.md with Apps/frontend/package.json paths

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: rename Apps/ to apps/ and fix backend path resolution

- Rename Apps/ folder to apps/ for consistency with JS/Node conventions
- Update all path references across CI/CD workflows, docs, and config files
- Fix frontend Python path resolver to look for 'backend' instead of 'auto-claude'
- Update path-resolver.ts to correctly find apps/backend in development mode

This completes the Apps restructure from PR #122 and prepares for v2.8.0 release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(electron): correct preload script path from .js to .mjs

electron-vite builds the preload script as ESM (index.mjs) but the main
process was looking for CommonJS (index.js). This caused the preload to
fail silently, making the app fall back to browser mock mode with fake
data and non-functional IPC handlers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* - Introduced `dev:debug` script to enable debugging during development.
- Added `dev:mcp` script for running the frontend in MCP mode.

These enhancements streamline the development process for frontend developers.

* refactor(memory): make Graphiti memory mandatory and remove Docker dependency

Memory is now a core component of Auto Claude rather than optional:
- Python 3.12+ is required for the backend (not just memory layer)
- Graphiti is enabled by default in .env.example
- Removed all FalkorDB/Docker references (migrated to embedded LadybugDB)
- Deleted guides/DOCKER-SETUP.md and docker-handlers.ts
- Updated onboarding UI to remove "optional" language
- Updated all documentation to reflect LadybugDB architecture

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add cross-platform Windows support for npm scripts

- Add scripts/install-backend.js for cross-platform Python venv setup
  - Auto-detects Python 3.12 (py -3.12 on Windows, python3.12 on Unix)
  - Handles platform-specific venv paths
- Add scripts/test-backend.js for cross-platform pytest execution
- Update package.json to use Node.js scripts instead of shell commands
- Update CONTRIBUTING.md with correct paths and instructions:
  - apps/backend/ and apps/frontend/ paths
  - Python 3.12 requirement (memory system now required)
  - Platform-specific install commands (winget, brew, apt)
  - npm instead of pnpm
  - Quick Start section with npm run install:all

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* remove doc

* fix(frontend): correct Ollama detector script path after apps restructure

The Ollama status check was failing because memory-handlers.ts
was looking for ollama_model_detector.py at auto-claude/ but the
script is now at apps/backend/ after the directory restructure.

This caused "Ollama not running" to display even when Ollama was
actually running and accessible.

* chore: bump version to 2.7.2

Downgrade version from 2.8.0 to 2.7.2 as the Apps/ restructure
is better suited as a patch release rather than a minor release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: update package-lock.json for Windows compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs(contributing): add hotfix workflow and update paths for apps/ structure

Add Git Flow hotfix workflow documentation with step-by-step guide
and ASCII diagram showing the branching strategy.

Update all paths from auto-claude/auto-claude-ui to apps/backend/apps/frontend
and migrate package manager references from pnpm to npm to match the
new project structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ci): remove duplicate ARM64 build from Intel runner

The Intel runner was building both x64 and arm64 architectures,
while a separate ARM64 runner also builds arm64 natively. This
caused duplicate ARM64 builds, wasting CI resources.

Now each runner builds only its native architecture:
- Intel runner: x64 only
- ARM64 runner: arm64 only

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Alex Madera <e.a_madera@hotmail.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 21:34:51 +01:00
AndyMik90 91f7051dda docs: Add Git Flow branching strategy to CONTRIBUTING.md
- Add comprehensive branching strategy documentation
- Explain main, develop, feature, fix, release, and hotfix branches
- Clarify that all PRs should target develop (not main)
- Add release process documentation for maintainers
- Update PR process to branch from develop
- Expand table of contents with new sections
2025-12-22 20:20:59 +01:00
2012 changed files with 180857 additions and 288413 deletions
+8 -4
View File
@@ -42,14 +42,18 @@ reviews:
# Path-specific review instructions
path_instructions:
- path: "apps/desktop/**/*.{ts,tsx}"
- path: "apps/backend/**/*.py"
instructions: |
Focus on Python best practices, type hints, and async patterns.
Check for proper error handling and security considerations.
Verify compatibility with Python 3.12+.
- path: "apps/frontend/**/*.{ts,tsx}"
instructions: |
Review React patterns and TypeScript type safety.
Check for proper state management and component composition.
Verify Vercel AI SDK v6 usage patterns and tool definitions.
- path: "apps/desktop/**/*.test.{ts,tsx}"
- path: "tests/**"
instructions: |
Ensure tests are comprehensive and follow Vitest conventions.
Ensure tests are comprehensive and follow pytest conventions.
Check for proper mocking and test isolation.
chat:
+1 -28
View File
@@ -33,21 +33,6 @@ Follow conventional commits: `<type>: <subject>`
**Example:** `feat: add user authentication system`
## AI Disclosure
<!-- Check the box below if any part of this PR was written with AI assistance. -->
- [ ] This PR includes AI-generated code (Claude, Codex, Copilot, etc.)
<!-- If checked, please also fill in: -->
**Tool(s) used:** <!-- e.g., Claude Code, GitHub Copilot, ChatGPT -->
**Testing level:**
- [ ] Untested -- AI output not yet verified
- [ ] Lightly tested -- ran the app / spot-checked key paths
- [ ] Fully tested -- all tests pass, manually verified behavior
- [ ] I understand what this PR does and how the underlying code works
## Checklist
- [ ] I've synced with `develop` branch
@@ -55,21 +40,9 @@ Follow conventional commits: `<type>: <subject>`
- [ ] I've followed the code principles (SOLID, DRY, KISS)
- [ ] My PR is small and focused (< 400 lines ideally)
## Platform Testing Checklist
**CRITICAL:** This project supports Windows, macOS, and Linux. Platform-specific bugs are a common source of breakage.
- [ ] **Windows tested** (either on Windows or via CI)
- [ ] **macOS tested** (either on macOS or via CI)
- [ ] **Linux tested** (CI covers this)
- [ ] Used centralized `platform/` module instead of direct `process.platform` checks
- [ ] No hardcoded paths (used `findExecutable()` or platform abstractions)
**If you only have access to one OS:** CI now tests on all platforms. Ensure all checks pass before submitting.
## CI/Testing Requirements
- [ ] All CI checks pass on **all platforms** (Windows, macOS, Linux)
- [ ] All CI checks pass
- [ ] All existing tests pass
- [ ] New features include test coverage
- [ ] Bug fixes include regression tests
@@ -1,160 +0,0 @@
name: 'Finalize macOS Notarization'
description: 'Wait for Apple notarization to complete and staple tickets to DMG files'
inputs:
apple-id:
description: 'Apple ID for notarization'
required: true
apple-app-specific-password:
description: 'Apple app-specific password'
required: true
apple-team-id:
description: 'Apple Team ID'
required: true
intel-notarization-id:
description: 'Notarization request ID for Intel build'
required: false
default: ''
arm64-notarization-id:
description: 'Notarization request ID for ARM64 build'
required: false
default: ''
intel-dmg-file:
description: 'Filename of the Intel DMG'
required: false
default: ''
arm64-dmg-file:
description: 'Filename of the ARM64 DMG'
required: false
default: ''
intel-artifact-path:
description: 'Path to Intel build artifacts'
required: false
default: 'intel'
arm64-artifact-path:
description: 'Path to ARM64 build artifacts'
required: false
default: 'arm64'
timeout:
description: 'Timeout in seconds for notarization wait'
required: false
default: '3600'
outputs:
intel-stapled:
description: 'Whether Intel DMG was successfully stapled'
value: ${{ steps.staple.outputs.intel_stapled }}
arm64-stapled:
description: 'Whether ARM64 DMG was successfully stapled'
value: ${{ steps.staple.outputs.arm64_stapled }}
runs:
using: 'composite'
steps:
- name: Wait for notarization and staple
id: staple
shell: bash
env:
APPLE_ID: ${{ inputs.apple-id }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
INTEL_NOTARIZATION_ID: ${{ inputs.intel-notarization-id }}
ARM64_NOTARIZATION_ID: ${{ inputs.arm64-notarization-id }}
INTEL_DMG: ${{ inputs.intel-dmg-file }}
ARM64_DMG: ${{ inputs.arm64-dmg-file }}
INTEL_PATH: ${{ inputs.intel-artifact-path }}
ARM64_PATH: ${{ inputs.arm64-artifact-path }}
TIMEOUT: ${{ inputs.timeout }}
run: |
intel_stapled=false
arm64_stapled=false
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization wait: APPLE_ID not configured"
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Warn if no notarization IDs provided (could indicate submission failure)
if [ -z "$INTEL_NOTARIZATION_ID" ] && [ -z "$ARM64_NOTARIZATION_ID" ]; then
echo "::warning::No notarization IDs provided - nothing to finalize. Check if notarization submission succeeded."
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Wait for Intel notarization
if [ -n "$INTEL_NOTARIZATION_ID" ]; then
echo "Waiting for Intel notarization: $INTEL_NOTARIZATION_ID"
if ! xcrun notarytool wait "$INTEL_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--timeout "$TIMEOUT"; then
echo "::error::Intel notarization failed or timed out"
exit 1
fi
# Verify notarization was accepted (not just processed)
INTEL_STATUS=$(xcrun notarytool info "$INTEL_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--output-format json | jq -r '.status // "Unknown"')
if [ "$INTEL_STATUS" != "Accepted" ]; then
echo "::error::Intel notarization status is '$INTEL_STATUS', expected 'Accepted'"
exit 1
fi
echo "Intel notarization status: $INTEL_STATUS"
# Verify DMG file exists before stapling
if [ ! -f "$INTEL_PATH/$INTEL_DMG" ]; then
echo "::error::Intel DMG not found at $INTEL_PATH/$INTEL_DMG"
exit 1
fi
echo "Stapling Intel DMG: $INTEL_PATH/$INTEL_DMG"
if ! xcrun stapler staple "$INTEL_PATH/$INTEL_DMG"; then
echo "::error::Failed to staple Intel DMG"
exit 1
fi
echo "Successfully stapled Intel DMG"
intel_stapled=true
fi
# Wait for ARM64 notarization
if [ -n "$ARM64_NOTARIZATION_ID" ]; then
echo "Waiting for ARM64 notarization: $ARM64_NOTARIZATION_ID"
if ! xcrun notarytool wait "$ARM64_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--timeout "$TIMEOUT"; then
echo "::error::ARM64 notarization failed or timed out"
exit 1
fi
# Verify notarization was accepted (not just processed)
ARM64_STATUS=$(xcrun notarytool info "$ARM64_NOTARIZATION_ID" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--output-format json | jq -r '.status // "Unknown"')
if [ "$ARM64_STATUS" != "Accepted" ]; then
echo "::error::ARM64 notarization status is '$ARM64_STATUS', expected 'Accepted'"
exit 1
fi
echo "ARM64 notarization status: $ARM64_STATUS"
# Verify DMG file exists before stapling
if [ ! -f "$ARM64_PATH/$ARM64_DMG" ]; then
echo "::error::ARM64 DMG not found at $ARM64_PATH/$ARM64_DMG"
exit 1
fi
echo "Stapling ARM64 DMG: $ARM64_PATH/$ARM64_DMG"
if ! xcrun stapler staple "$ARM64_PATH/$ARM64_DMG"; then
echo "::error::Failed to staple ARM64 DMG"
exit 1
fi
echo "Successfully stapled ARM64 DMG"
arm64_stapled=true
fi
echo "intel_stapled=$intel_stapled" >> "$GITHUB_OUTPUT"
echo "arm64_stapled=$arm64_stapled" >> "$GITHUB_OUTPUT"
@@ -1,194 +0,0 @@
name: 'Merge macOS Manifests'
description: 'Merge Intel and ARM64 macOS manifests for electron-updater'
inputs:
dist-path:
description: 'Path to the dist directory containing build artifacts'
required: false
default: 'dist'
output-path:
description: 'Path to output the merged manifest'
required: false
default: 'release-assets'
copy-other-manifests:
description: 'Whether to copy Windows/Linux manifests as well'
required: false
default: 'true'
yq-version:
description: 'Version of yq to use for YAML merging'
required: false
default: 'v4.44.3'
outputs:
merged:
description: 'Whether manifests were merged (true) or single architecture used (false)'
value: ${{ steps.merge.outputs.merged }}
file-count:
description: 'Number of files in the merged manifest'
value: ${{ steps.validate.outputs.file_count }}
runs:
using: 'composite'
steps:
- name: Merge macOS manifests
id: merge
shell: bash
env:
# yq SHA256 checksum for v4.44.3 linux_amd64
# When updating yq-version, update this checksum and the one in validate step
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
run: |
echo "=== Merging macOS update manifests ==="
# Find all latest-mac.yml files from different build artifacts
intel_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-intel-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
arm64_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-arm64-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
echo "Intel manifest: ${intel_manifest:-not found}"
echo "ARM64 manifest: ${arm64_manifest:-not found}"
mkdir -p "${{ inputs.output-path }}"
if [ -n "$intel_manifest" ] && [ -n "$arm64_manifest" ]; then
echo "Both architectures found - merging manifests..."
echo "merged=true" >> "$GITHUB_OUTPUT"
# Install yq for YAML merging (pinned version with checksum verification)
YQ_VERSION="${{ inputs.yq-version }}"
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "Downloading yq ${YQ_VERSION}..."
if ! wget -qO /tmp/yq "$YQ_URL"; then
echo "::error::Failed to download yq ${YQ_VERSION}"
exit 1
fi
# Verify checksum
echo "Verifying yq checksum..."
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
echo "::error::yq checksum verification failed!"
echo "Expected: $YQ_SHA256"
echo "Actual: $ACTUAL_SHA256"
rm -f /tmp/yq
exit 1
fi
echo "Checksum verified successfully"
sudo mv /tmp/yq /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
echo "Installed yq version:"
yq --version
# Merge the files arrays from both manifests using two-step approach
# Step 1: Collect all files from both manifests into a temp file
yq eval-all '[.files] | flatten' "$intel_manifest" "$arm64_manifest" > /tmp/merged-files.yml
# Step 2: Replace files array in first manifest with merged files
yq eval '.files = load("/tmp/merged-files.yml")' "$intel_manifest" > "${{ inputs.output-path }}/latest-mac.yml"
echo "Merged manifest contents:"
cat "${{ inputs.output-path }}/latest-mac.yml"
elif [ -n "$intel_manifest" ]; then
echo "Only Intel manifest found - using as-is"
echo "merged=false" >> "$GITHUB_OUTPUT"
cp "$intel_manifest" "${{ inputs.output-path }}/latest-mac.yml"
elif [ -n "$arm64_manifest" ]; then
echo "Only ARM64 manifest found - using as-is"
echo "merged=false" >> "$GITHUB_OUTPUT"
cp "$arm64_manifest" "${{ inputs.output-path }}/latest-mac.yml"
else
echo "::error::No macOS manifests found - this will cause auto-update to fail"
exit 1
fi
- name: Validate merged manifest
id: validate
shell: bash
env:
# Single source of truth for yq checksum - must match merge step
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
run: |
manifest_file="${{ inputs.output-path }}/latest-mac.yml"
echo "=== Validating merged manifest ==="
# Check file exists
if [ ! -f "$manifest_file" ]; then
echo "::error::Merged manifest file not found at $manifest_file"
exit 1
fi
# Install yq if not already installed (for single-arch case)
if ! command -v yq &> /dev/null; then
YQ_VERSION="${{ inputs.yq-version }}"
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
echo "Downloading yq ${YQ_VERSION}..."
wget -qO /tmp/yq "$YQ_URL"
# Verify checksum (YQ_SHA256 from env)
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
echo "::error::yq checksum verification failed!"
echo "Expected: $YQ_SHA256"
echo "Actual: $ACTUAL_SHA256"
exit 1
fi
sudo mv /tmp/yq /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
fi
# Validate YAML is parseable
if ! yq eval '.' "$manifest_file" > /dev/null 2>&1; then
echo "::error::Merged manifest is not valid YAML"
cat "$manifest_file"
exit 1
fi
echo "YAML syntax is valid"
# Count files in manifest
file_count=$(yq eval '.files | length' "$manifest_file")
echo "file_count=$file_count" >> "$GITHUB_OUTPUT"
echo "Manifest contains $file_count file entries"
# Validate file count
if [ "$file_count" -eq 0 ]; then
echo "::error::Merged manifest contains no files"
exit 1
fi
# If we merged both architectures, expect at least 2 files (one per arch)
if [ "${{ steps.merge.outputs.merged }}" = "true" ] && [ "$file_count" -lt 2 ]; then
echo "::warning::Merged manifest has fewer than 2 files - merge may have failed"
fi
# Validate required fields exist
if ! yq eval '.version' "$manifest_file" | grep -q .; then
echo "::error::Manifest missing 'version' field"
exit 1
fi
echo "Version field present: $(yq eval '.version' "$manifest_file")"
echo "Manifest validation passed"
- name: Copy other manifests
if: inputs.copy-other-manifests == 'true'
shell: bash
run: |
echo "=== Copying other update manifests ==="
# Copy other manifests (Windows, Linux) - these don't have the duplicate issue
for manifest in latest.yml latest-linux.yml latest-linux-arm64.yml; do
found=$(find "${{ inputs.dist-path }}" -name "$manifest" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then
echo "Copying $manifest"
cp "$found" "${{ inputs.output-path }}/"
fi
done
echo ""
echo "=== Manifest files in ${{ inputs.output-path }} ==="
ls -la "${{ inputs.output-path }}"/*.yml 2>/dev/null || echo "No manifest files found"
@@ -1,126 +0,0 @@
name: 'Setup Node.js Frontend'
description: 'Set up Node.js with npm and cached dependencies for the frontend'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '24'
ignore-scripts:
description: 'Whether to use --ignore-scripts flag during npm ci'
required: false
default: 'false'
outputs:
cache-hit:
description: 'Whether npm cache was hit'
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Setup Node.js ${{ inputs.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Get npm cache directory
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- name: Cache npm dependencies
id: cache
uses: actions/cache@v4
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
shell: bash
# Run npm ci from root to properly handle workspace dependencies.
# With npm workspaces, the lock file is at root and dependencies are hoisted there.
# Running npm ci in apps/desktop would fail to populate node_modules correctly.
run: |
if [ "${{ inputs.ignore-scripts }}" == "true" ]; then
npm ci --ignore-scripts
else
npm ci
fi
- name: Link node_modules for electron-builder
shell: bash
# electron-builder expects node_modules in apps/desktop for native module rebuilding.
# With npm workspaces, packages are hoisted to root. Create a link so electron-builder
# can find the modules during packaging and code signing.
# Uses symlink on Unix, directory junction on Windows (works without admin privileges).
#
# IMPORTANT: npm workspaces may create a partial node_modules in apps/desktop for
# packages that couldn't be hoisted. We must remove it and create a proper link to root.
run: |
# Verify npm ci succeeded
if [ ! -d "node_modules" ]; then
echo "::error::Root node_modules does not exist. npm ci may have failed."
exit 1
fi
# Remove any existing node_modules in apps/desktop
# This handles: partial directories from npm workspaces, AND broken symlinks
if [ -e "apps/desktop/node_modules" ] || [ -L "apps/desktop/node_modules" ]; then
# Check if it's a valid symlink pointing to root node_modules
if [ -L "apps/desktop/node_modules" ]; then
target=$(readlink apps/desktop/node_modules 2>/dev/null || echo "")
if [ "$target" = "../../node_modules" ] && [ -d "apps/desktop/node_modules" ]; then
echo "Correct symlink already exists: apps/desktop/node_modules -> ../../node_modules"
else
echo "Removing incorrect/broken symlink (was: $target)..."
rm -f "apps/desktop/node_modules"
fi
else
echo "Removing partial node_modules directory created by npm workspaces..."
rm -rf "apps/desktop/node_modules"
fi
fi
# Create link if it doesn't exist or was removed
if [ ! -L "apps/desktop/node_modules" ]; then
if [ "$RUNNER_OS" == "Windows" ]; then
# Use directory junction on Windows (works without admin privileges)
# Use PowerShell's New-Item -ItemType Junction for reliable path handling
abs_target=$(cygpath -w "$(pwd)/node_modules")
link_path=$(cygpath -w "$(pwd)/apps/desktop/node_modules")
powershell -Command "New-Item -ItemType Junction -Path '$link_path' -Target '$abs_target'" > /dev/null
if [ $? -eq 0 ]; then
echo "Created junction: apps/desktop/node_modules -> $abs_target"
else
echo "::error::Failed to create directory junction on Windows"
exit 1
fi
else
# Use symlink on Unix (macOS/Linux)
if ln -s ../../node_modules apps/desktop/node_modules; then
echo "Created symlink: apps/desktop/node_modules -> ../../node_modules"
else
echo "::error::Failed to create symlink"
exit 1
fi
fi
fi
# Final verification - the link must exist and resolve correctly
# Note: On Windows, junctions don't show as symlinks (-L), so we check if the directory exists
# and can be listed. On Unix, we also verify it's a symlink.
if [ "$RUNNER_OS" != "Windows" ] && [ ! -L "apps/desktop/node_modules" ]; then
echo "::error::apps/desktop/node_modules symlink was not created"
exit 1
fi
# Verify the link resolves to a valid directory with content
if ! ls apps/desktop/node_modules/electron >/dev/null 2>&1; then
echo "::error::apps/desktop/node_modules does not resolve correctly (electron not found)"
ls -la apps/desktop/ || true
ls apps/desktop/node_modules 2>&1 | head -5 || true
exit 1
fi
count=$(ls apps/desktop/node_modules 2>/dev/null | wc -l)
echo "Verified: apps/desktop/node_modules resolves correctly ($count entries)"
@@ -1,87 +0,0 @@
name: 'Submit macOS Notarization'
description: 'Submit a macOS DMG file for Apple notarization asynchronously'
inputs:
apple-id:
description: 'Apple ID for notarization'
required: true
apple-app-specific-password:
description: 'Apple app-specific password'
required: true
apple-team-id:
description: 'Apple Team ID'
required: true
dmg-path:
description: 'Path to the dist directory containing the DMG file'
required: false
default: 'apps/desktop/dist'
outputs:
notarization-id:
description: 'The notarization request ID'
value: ${{ steps.submit.outputs.notarization_id }}
dmg-file:
description: 'The DMG filename that was submitted'
value: ${{ steps.submit.outputs.dmg_file }}
runs:
using: 'composite'
steps:
- name: Submit notarization (async)
id: submit
shell: bash
env:
APPLE_ID: ${{ inputs.apple-id }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
DMG_PATH: ${{ inputs.dmg-path }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
echo "notarization_id=" >> "$GITHUB_OUTPUT"
echo "dmg_file=" >> "$GITHUB_OUTPUT"
exit 0
fi
# Find the DMG file
DMG_FILE=$(find "$DMG_PATH" -name "*.dmg" -type f | head -1)
if [ -z "$DMG_FILE" ]; then
echo "::error::No DMG file found in $DMG_PATH"
exit 1
fi
echo "Submitting $DMG_FILE for notarization (async)..."
# Submit for notarization without waiting
# Capture both stdout and exit code
set +e
RESULT=$(xcrun notarytool submit "$DMG_FILE" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--no-wait \
--output-format json 2>&1)
SUBMIT_EXIT_CODE=$?
set -e
echo "$RESULT"
# Check if submission command itself failed (not just missing ID)
if [ $SUBMIT_EXIT_CODE -ne 0 ]; then
echo "::error::notarytool submit failed with exit code $SUBMIT_EXIT_CODE"
exit 1
fi
# Extract the notarization ID from JSON response
# jq is always available on macOS runners
NOTARIZATION_ID=$(echo "$RESULT" | jq -r '.id // empty' 2>/dev/null)
if [ -z "$NOTARIZATION_ID" ]; then
echo "::error::Failed to get notarization ID from response"
echo "Response was: $RESULT"
exit 1
fi
echo "Notarization submitted with ID: $NOTARIZATION_ID"
echo "notarization_id=$NOTARIZATION_ID" >> "$GITHUB_OUTPUT"
echo "dmg_file=$(basename "$DMG_FILE")" >> "$GITHUB_OUTPUT"
+13 -1
View File
@@ -1,8 +1,20 @@
version: 2
updates:
# Python dependencies
- package-ecosystem: pip
directory: /apps/backend
schedule:
interval: weekly
open-pull-requests-limit: 5
labels:
- dependencies
- python
commit-message:
prefix: "chore(deps)"
# npm dependencies
- package-ecosystem: npm
directory: /apps/desktop
directory: /apps/frontend
schedule:
interval: weekly
open-pull-requests-limit: 5
+174 -413
View File
@@ -65,262 +65,216 @@ jobs:
build-macos-intel:
needs: create-tag
runs-on: macos-15-intel
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
with:
# Use tag for real releases, develop branch for dry runs
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/desktop && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
run: cd apps/frontend && npm run build
- name: Package macOS (Intel)
run: |
VERSION="${{ needs.create-tag.outputs.version }}"
cd apps/desktop && npm run package:mac -- --x64 --config.extraMetadata.version="$VERSION"
cd apps/frontend && npm run package:mac -- --x64 --config.extraMetadata.version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Notarize macOS Intel app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-intel-builds
path: |
apps/desktop/dist/*.dmg
apps/desktop/dist/*.zip
apps/desktop/dist/*.yml
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
apps/frontend/dist/*.yml
# Apple Silicon build on ARM64 runner for native compilation
build-macos-arm64:
needs: create-tag
runs-on: macos-15
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
with:
# Use tag for real releases, develop branch for dry runs
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/desktop && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
run: cd apps/frontend && npm run build
- name: Package macOS (Apple Silicon)
run: |
VERSION="${{ needs.create-tag.outputs.version }}"
cd apps/desktop && npm run package:mac -- --arm64 --config.extraMetadata.version="$VERSION"
cd apps/frontend && npm run package:mac -- --arm64 --config.extraMetadata.version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Notarize macOS ARM64 app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-arm64-builds
path: |
apps/desktop/dist/*.dmg
apps/desktop/dist/*.zip
apps/desktop/dist/*.yml
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
apps/frontend/dist/*.yml
build-windows:
needs: create-tag
runs-on: windows-latest
permissions:
id-token: write # Required for OIDC authentication with Azure
contents: read
env:
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
steps:
- uses: actions/checkout@v4
with:
# Use tag for real releases, develop branch for dry runs
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
shell: bash
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/desktop && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
run: cd apps/frontend && npm run build
- name: Package Windows
shell: bash
run: |
VERSION="${{ needs.create-tag.outputs.version }}"
cd apps/desktop && npm run package:win -- --config.extraMetadata.version="$VERSION"
cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
CSC_IDENTITY_AUTO_DISCOVERY: false
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Azure Login (OIDC)
if: env.AZURE_CLIENT_ID != ''
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Windows executable with Azure Trusted Signing
if: env.AZURE_CLIENT_ID != ''
uses: azure/trusted-signing-action@v0.5.11
with:
endpoint: https://neu.codesigning.azure.net/
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
files-folder: apps/desktop/dist
files-folder-filter: exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Verify Windows executable is signed
if: env.AZURE_CLIENT_ID != ''
shell: pwsh
run: |
cd apps/desktop/dist
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
Write-Host "Verifying signature on $($exeFile.Name)..."
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
if ($sig.Status -ne 'Valid') {
Write-Host "::error::Signature verification failed: $($sig.Status)"
Write-Host "::error::Status Message: $($sig.StatusMessage)"
exit 1
}
Write-Host "✅ Signature verified successfully"
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
} else {
Write-Host "::error::No .exe file found to verify"
exit 1
}
- name: Regenerate checksums after signing
if: env.AZURE_CLIENT_ID != ''
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
cd apps/desktop/dist
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
# electron-builder produces one installer exe per build
$exeFiles = Get-ChildItem -Filter "*.exe"
if ($exeFiles.Count -eq 0) {
Write-Host "::error::No .exe files found in dist folder"
exit 1
}
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
$ymlFile = "latest.yml"
if (-not (Test-Path $ymlFile)) {
Write-Host "::error::$ymlFile not found - cannot update checksums"
exit 1
}
$content = Get-Content $ymlFile -Raw
$originalContent = $content
# Process each exe file and update its hash in latest.yml
foreach ($exeFile in $exeFiles) {
Write-Host "Processing $($exeFile.Name)..."
# Compute SHA512 hash and convert to base64 (electron-builder format)
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hashBytes = $sha512.ComputeHash($bytes)
$hash = [System.Convert]::ToBase64String($hashBytes)
$size = $exeFile.Length
Write-Host " Hash: $hash"
Write-Host " Size: $size"
}
# For electron-builder, latest.yml has a single file entry for the installer
# Update the sha512 and size for the primary exe (first one, typically the installer)
$primaryExe = $exeFiles | Select-Object -First 1
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hashBytes = $sha512.ComputeHash($bytes)
$hash = [System.Convert]::ToBase64String($hashBytes)
$size = $primaryExe.Length
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
# Update size
$content = $content -replace 'size: \d+', "size: $size"
if ($content -eq $originalContent) {
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
exit 1
}
Set-Content -Path $ymlFile -Value $content -NoNewline
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
- name: Skip signing notice
if: env.AZURE_CLIENT_ID == ''
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-builds
path: |
apps/desktop/dist/*.exe
apps/desktop/dist/*.yml
apps/frontend/dist/*.exe
apps/frontend/dist/*.yml
build-linux:
needs: create-tag
@@ -331,92 +285,53 @@ jobs:
# Use tag for real releases, develop branch for dry runs
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Setup Flatpak and verification tools
run: |
set -e
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder squashfs-tools
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/desktop && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
run: cd apps/frontend && npm run build
- name: Package Linux
run: |
VERSION="${{ needs.create-tag.outputs.version }}"
cd apps/desktop && npm run package:linux -- --config.extraMetadata.version="$VERSION"
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Verify Linux packages
run: cd apps/desktop && npm run verify:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: |
apps/desktop/dist/*.AppImage
apps/desktop/dist/*.deb
apps/desktop/dist/*.flatpak
apps/desktop/dist/*.yml
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
finalize-notarization:
needs: [build-macos-intel, build-macos-arm64]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Download Intel DMG
uses: actions/download-artifact@v7
with:
name: macos-intel-builds
path: intel
- name: Download ARM64 DMG
uses: actions/download-artifact@v7
with:
name: macos-arm64-builds
path: arm64
- name: Wait for notarization and staple
uses: ./.github/actions/finalize-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
- name: Upload stapled Intel DMG
uses: actions/upload-artifact@v4
with:
name: macos-intel-stapled
path: intel/*.dmg
- name: Upload stapled ARM64 DMG
uses: actions/upload-artifact@v4
with:
name: macos-arm64-stapled
path: arm64/*.dmg
apps/frontend/dist/*.AppImage
apps/frontend/dist/*.deb
apps/frontend/dist/*.yml
create-release:
needs: [create-tag, finalize-notarization, build-windows, build-linux]
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.dry_run != 'true' }}
permissions:
@@ -428,114 +343,25 @@ jobs:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
path: dist
- name: Flatten binary artifacts
- name: Flatten and validate artifacts
run: |
mkdir -p release-assets
# Copy stapled macOS DMGs (from finalize-notarization job)
# Validate that stapled DMGs exist before copying
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
else
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
fi
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts (including blockmap for delta updates)
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.yml" \) -exec cp {} release-assets/ \;
# Validate that at least one artifact was copied
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
if [ "$artifact_count" -eq 0 ]; then
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
exit 1
fi
echo "Found $artifact_count binary artifact(s):"
echo "Found $artifact_count artifact(s):"
ls -la release-assets/
# Merge macOS manifests from Intel and ARM64 builds
# See: https://github.com/electron-userland/electron-builder/issues/5592
- name: Merge macOS manifests
uses: ./.github/actions/merge-macos-manifests
with:
dist-path: dist
output-path: release-assets
copy-other-manifests: 'true'
- name: Rename and validate beta manifests
run: |
cd release-assets
echo "=== Current manifest files ==="
ls -la *.yml 2>/dev/null || echo "No yml files found yet"
# electron-builder generates latest*.yml files by default
# For beta channel, electron-updater expects beta*.yml files
# Rename: latest.yml -> beta.yml, latest-mac.yml -> beta-mac.yml, latest-linux.yml -> beta-linux.yml
# Windows: latest.yml -> beta.yml
if [ -f "latest.yml" ]; then
echo "Renaming latest.yml -> beta.yml (Windows)"
mv latest.yml beta.yml
fi
# macOS: latest-mac.yml -> beta-mac.yml
if [ -f "latest-mac.yml" ]; then
echo "Renaming latest-mac.yml -> beta-mac.yml (macOS)"
mv latest-mac.yml beta-mac.yml
fi
# Linux: latest-linux.yml -> beta-linux.yml
if [ -f "latest-linux.yml" ]; then
echo "Renaming latest-linux.yml -> beta-linux.yml (Linux)"
mv latest-linux.yml beta-linux.yml
fi
# Linux ARM64: latest-linux-arm64.yml -> beta-linux-arm64.yml (if exists)
if [ -f "latest-linux-arm64.yml" ]; then
echo "Renaming latest-linux-arm64.yml -> beta-linux-arm64.yml (Linux ARM64)"
mv latest-linux-arm64.yml beta-linux-arm64.yml
fi
echo ""
echo "=== Beta manifest files after rename ==="
ls -la *.yml 2>/dev/null || echo "No yml files found"
# Validate required beta manifests exist
missing_manifests=""
if [ ! -f "beta-mac.yml" ]; then
missing_manifests="$missing_manifests beta-mac.yml"
fi
if [ ! -f "beta.yml" ]; then
missing_manifests="$missing_manifests beta.yml"
fi
if [ ! -f "beta-linux.yml" ]; then
missing_manifests="$missing_manifests beta-linux.yml"
fi
if [ -n "$missing_manifests" ]; then
echo "::error::Missing required beta manifests:$missing_manifests"
echo "::error::Auto-update will fail on affected platforms without these files!"
exit 1
fi
echo ""
echo "All required beta manifests present:"
echo " - beta-mac.yml (macOS)"
echo " - beta.yml (Windows)"
echo " - beta-linux.yml (Linux)"
- name: Generate checksums
run: |
cd release-assets
@@ -570,89 +396,24 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
dry-run-summary:
needs: [create-tag, finalize-notarization, build-windows, build-linux]
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.dry_run == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
path: dist
- name: Flatten binary artifacts
run: |
mkdir -p release-assets
# Copy stapled macOS DMGs (from finalize-notarization job)
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts (including blockmap for delta updates)
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Merge macOS manifests (same logic as real release)
- name: Merge macOS manifests
uses: ./.github/actions/merge-macos-manifests
with:
dist-path: dist
output-path: release-assets
copy-other-manifests: 'true'
- name: Validate and rename beta manifests
run: |
cd release-assets
# Rename latest*.yml to beta*.yml
[ -f "latest.yml" ] && mv latest.yml beta.yml
[ -f "latest-mac.yml" ] && mv latest-mac.yml beta-mac.yml
[ -f "latest-linux.yml" ] && mv latest-linux.yml beta-linux.yml
[ -f "latest-linux-arm64.yml" ] && mv latest-linux-arm64.yml beta-linux-arm64.yml
# Validate required manifests
missing=""
[ ! -f "beta-mac.yml" ] && missing="$missing beta-mac.yml"
[ ! -f "beta.yml" ] && missing="$missing beta.yml"
[ ! -f "beta-linux.yml" ] && missing="$missing beta-linux.yml"
if [ -n "$missing" ]; then
echo "::warning::DRY RUN: Missing required beta manifests:$missing"
echo "MANIFEST_STATUS=FAILED" >> $GITHUB_ENV
else
echo "MANIFEST_STATUS=PASSED" >> $GITHUB_ENV
# Show merged manifest content for verification
echo ""
echo "=== beta-mac.yml content (should have both architectures) ==="
cat beta-mac.yml
fi
- name: Dry run summary
run: |
echo "## Beta Release Dry Run Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ needs.create-tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Build Artifacts" >> $GITHUB_STEP_SUMMARY
echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) >> $GITHUB_STEP_SUMMARY
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Update Manifests (Required for Auto-Update)" >> $GITHUB_STEP_SUMMARY
if [ "$MANIFEST_STATUS" = "PASSED" ]; then
echo "All required beta manifests present:" >> $GITHUB_STEP_SUMMARY
echo "- beta-mac.yml (macOS)" >> $GITHUB_STEP_SUMMARY
echo "- beta.yml (Windows)" >> $GITHUB_STEP_SUMMARY
echo "- beta-linux.yml (Linux)" >> $GITHUB_STEP_SUMMARY
else
echo "**WARNING: Missing required manifests! Auto-update will fail.**" >> $GITHUB_STEP_SUMMARY
echo "Check build logs for details." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY
+9 -9
View File
@@ -10,11 +10,11 @@ on:
electron_version:
description: 'Electron version to build for'
required: false
default: '40.0.0'
default: '39.2.6'
env:
# Default Electron version - update when upgrading Electron in package.json
ELECTRON_VERSION: ${{ github.event.inputs.electron_version || '40.0.0' }}
ELECTRON_VERSION: ${{ github.event.inputs.electron_version || '39.2.6' }}
jobs:
build-windows:
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '24'
@@ -38,7 +38,7 @@ jobs:
uses: microsoft/setup-msbuild@v2
- name: Install node-pty and rebuild for Electron
working-directory: apps/desktop
working-directory: apps/frontend
shell: pwsh
run: |
# Install only node-pty
@@ -52,7 +52,7 @@ jobs:
npx @electron/rebuild --version $env:ELECTRON_VERSION --module-dir node_modules/node-pty --arch ${{ matrix.arch }}
- name: Package prebuilt binaries
working-directory: apps/desktop
working-directory: apps/frontend
shell: pwsh
run: |
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
@@ -78,7 +78,7 @@ jobs:
Get-ChildItem $prebuildDir
- name: Create archive
working-directory: apps/desktop
working-directory: apps/frontend
shell: pwsh
run: |
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
@@ -93,14 +93,14 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: node-pty-win32-${{ matrix.arch }}
path: apps/desktop/node-pty-*.zip
path: apps/frontend/node-pty-*.zip
retention-days: 90
- name: Upload to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v1
with:
files: apps/desktop/node-pty-*.zip
files: apps/frontend/node-pty-*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -110,7 +110,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
path: artifacts
+84 -90
View File
@@ -1,31 +1,10 @@
# Cross-Platform CI Pipeline
#
# Tests on all target platforms (Linux, Windows, macOS) to catch
# platform-specific bugs before they merge. ALL platforms must pass.
#
# Optimized: Frontend-only matrix, path filters to skip on docs-only changes.
name: CI
on:
push:
branches: [main, develop]
paths:
- 'apps/**'
- 'package*.json'
- 'tsconfig*.json'
- 'biome.jsonc'
- '.github/workflows/ci.yml'
- '.github/actions/**'
pull_request:
branches: [main, develop]
paths:
- 'apps/**'
- 'package*.json'
- 'tsconfig*.json'
- 'biome.jsonc'
- '.github/workflows/ci.yml'
- '.github/actions/**'
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
@@ -34,86 +13,101 @@ concurrency:
permissions:
contents: read
actions: read
pull-requests: write
jobs:
# --------------------------------------------------------------------------
# Frontend Tests - All Platforms
# --------------------------------------------------------------------------
test-frontend:
name: test-frontend (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# Python tests
test-python:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.12', '3.13']
steps:
- name: Checkout repository
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js frontend
uses: ./.github/actions/setup-node-frontend
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
ignore-scripts: 'true'
python-version: ${{ matrix.python-version }}
- name: Run TypeScript type check
working-directory: apps/desktop
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
if: matrix.python-version != '3.12'
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --tb=short -x
- name: Run tests with coverage
if: matrix.python-version == '3.12'
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
- name: Upload coverage reports
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
file: ./apps/backend/coverage.xml
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# Frontend lint, typecheck, test, and build
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Lint
working-directory: apps/frontend
run: npm run lint
- name: Type check
working-directory: apps/frontend
run: npm run typecheck
- name: Run unit tests with coverage
if: matrix.os == 'ubuntu-latest'
working-directory: apps/desktop
run: npm run test:coverage
- name: Run tests
working-directory: apps/frontend
run: npm run test
- name: Run unit tests
if: matrix.os != 'ubuntu-latest'
working-directory: apps/desktop
run: npm run test:unit
- name: Run integration tests
working-directory: apps/desktop
run: npm run test:integration
- name: Upload coverage report
if: matrix.os == 'ubuntu-latest' && always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: apps/desktop/coverage/
retention-days: 14
- name: Coverage PR comment
if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request'
uses: davelosert/vitest-coverage-report-action@v2
with:
working-directory: apps/desktop
json-summary-path: coverage/coverage-summary.json
json-final-path: coverage/coverage-final.json
- name: Build application
working-directory: apps/desktop
- name: Build
working-directory: apps/frontend
run: npm run build
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
ci-complete:
name: CI Complete
runs-on: ubuntu-latest
needs: [test-frontend]
if: always()
steps:
- name: Check all CI jobs passed
run: |
echo "CI Job Results:"
echo " test-frontend: ${{ needs.test-frontend.result }}"
echo ""
if [[ "${{ needs.test-frontend.result }}" != "success" ]]; then
echo "❌ One or more CI jobs failed"
exit 1
fi
echo "✅ All CI checks passed"
-1
View File
@@ -3,7 +3,6 @@ name: Discord Release Notification
on:
release:
types: [published]
workflow_dispatch:
jobs:
discord-notification:
-62
View File
@@ -1,62 +0,0 @@
# E2E Tests
#
# Runs Playwright E2E tests for the Electron desktop app on Linux.
# Ubuntu-only since Electron E2E is platform-agnostic (Chromium renderer).
# Non-blocking initially — separate from ci-complete gate while stabilizing.
name: E2E
on:
push:
branches: [main, develop]
paths:
- 'apps/**'
- '.github/workflows/e2e.yml'
pull_request:
branches: [main, develop]
paths:
- 'apps/**'
- '.github/workflows/e2e.yml'
concurrency:
group: e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
e2e:
name: E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js frontend
uses: ./.github/actions/setup-node-frontend
- name: Install Playwright browsers
working-directory: apps/desktop
run: npx playwright install --with-deps chromium
- name: Build application
working-directory: apps/desktop
run: npm run build
- name: Run E2E tests
working-directory: apps/desktop
continue-on-error: true # Non-blocking while stabilizing — pre-existing __dirname ESM issue
run: xvfb-run --auto-servernum npm run test:e2e
- name: Upload E2E report
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-report
path: |
apps/desktop/e2e/playwright-report/
apps/desktop/e2e/test-results/
retention-days: 14
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
issues: write
steps:
- name: Add area label from form
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
+13 -39
View File
@@ -3,58 +3,32 @@ name: Lint
on:
push:
branches: [main, develop]
paths:
- 'apps/desktop/**'
- '.github/workflows/lint.yml'
- '.github/actions/**'
- 'apps/desktop/biome.jsonc'
pull_request:
branches: [main, develop]
paths:
- 'apps/desktop/**'
- '.github/workflows/lint.yml'
- '.github/actions/**'
- 'apps/desktop/biome.jsonc'
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# TypeScript/JavaScript linting (Biome) - 15-25x faster than ESLint
typescript:
name: TypeScript (Biome)
# Python linting
python:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Pin version to match package.json for consistent behavior
- name: Setup Biome
uses: biomejs/setup-biome@v2
- name: Set up Python
uses: actions/setup-python@v5
with:
version: 2.3.11
python-version: '3.12'
- name: Run Biome
working-directory: apps/desktop
# biome ci fails on errors by default; warnings are reported but don't block
# Use --error-on-warnings when ready to enforce all rules
run: biome ci .
- name: Install ruff
run: pip install ruff
- name: Run ruff check
run: ruff check apps/backend/ --output-format=github
- name: Run ruff format check
run: ruff format apps/backend/ --check --diff
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
lint-complete:
name: Lint Complete
runs-on: ubuntu-latest
needs: [typescript]
if: always()
steps:
- name: Check lint results
run: |
if [[ "${{ needs.typescript.result }}" != "success" ]]; then
echo "❌ Linting failed"
echo " TypeScript: ${{ needs.typescript.result }}"
exit 1
fi
echo "✅ All linting passed"
+227
View File
@@ -0,0 +1,227 @@
name: PR Auto Label
on:
pull_request:
types: [opened, synchronize, reopened]
# Cancel in-progress runs for the same PR
concurrency:
group: pr-auto-label-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
label:
name: Auto Label PR
runs-on: ubuntu-latest
# Don't run on fork PRs (they can't write labels)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Auto-label PR
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title;
console.log(`::group::PR #${prNumber} - Auto-labeling`);
console.log(`Title: ${title}`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// ═══════════════════════════════════════════════════════════════
// TYPE LABELS (from PR title - Conventional Commits)
// ═══════════════════════════════════════════════════════════════
const typeMap = {
'feat': 'feature',
'fix': 'bug',
'docs': 'documentation',
'refactor': 'refactor',
'test': 'test',
'ci': 'ci',
'chore': 'chore',
'perf': 'performance',
'style': 'style',
'build': 'build'
};
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
if (typeMatch) {
const type = typeMatch[1].toLowerCase();
const isBreaking = typeMatch[3] === '!';
if (typeMap[type]) {
labelsToAdd.add(typeMap[type]);
console.log(` 📝 Type: ${type} → ${typeMap[type]}`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log(` ⚠️ Breaking change detected`);
}
} else {
console.log(` ⚠️ No conventional commit prefix found in title`);
}
// ═══════════════════════════════════════════════════════════════
// AREA LABELS (from changed files)
// ═══════════════════════════════════════════════════════════════
let files = [];
try {
const { data } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100
});
files = data;
} catch (e) {
console.log(` ⚠️ Could not fetch files: ${e.message}`);
}
const areas = {
frontend: false,
backend: false,
ci: false,
docs: false,
tests: false
};
for (const file of files) {
const path = file.filename;
if (path.startsWith('apps/frontend/')) areas.frontend = true;
if (path.startsWith('apps/backend/')) areas.backend = true;
if (path.startsWith('.github/')) areas.ci = true;
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
}
// Determine area label (mutually exclusive)
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
if (areas.frontend && areas.backend) {
labelsToAdd.add('area/fullstack');
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: fullstack (${files.length} files)`);
} else if (areas.frontend) {
labelsToAdd.add('area/frontend');
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: frontend (${files.length} files)`);
} else if (areas.backend) {
labelsToAdd.add('area/backend');
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: backend (${files.length} files)`);
} else if (areas.ci) {
labelsToAdd.add('area/ci');
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: ci (${files.length} files)`);
}
// ═══════════════════════════════════════════════════════════════
// SIZE LABELS (from lines changed)
// ═══════════════════════════════════════════════════════════════
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalLines = additions + deletions;
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
let sizeLabel;
if (totalLines < 10) sizeLabel = 'size/XS';
else if (totalLines < 100) sizeLabel = 'size/S';
else if (totalLines < 500) sizeLabel = 'size/M';
else if (totalLines < 1000) sizeLabel = 'size/L';
else sizeLabel = 'size/XL';
labelsToAdd.add(sizeLabel);
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
console.log('::endgroup::');
// ═══════════════════════════════════════════════════════════════
// APPLY LABELS
// ═══════════════════════════════════════════════════════════════
console.log(`::group::Applying labels`);
// Remove old labels (in parallel)
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
if (removeArray.length > 0) {
const removePromises = removeArray.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
});
await Promise.all(removePromises);
}
// Add new labels
const addArray = [...labelsToAdd];
if (addArray.length > 0) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: addArray
});
console.log(` ✓ Added: ${addArray.join(', ')}`);
} catch (e) {
// Some labels might not exist
if (e.status === 404) {
core.warning(`Some labels do not exist. Please create them in repository settings.`);
// Try adding one by one
for (const label of addArray) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [label]
});
} catch (e2) {
console.log(` ⚠ Label '${label}' does not exist`);
}
}
} else {
throw e;
}
}
}
console.log('::endgroup::');
// Summary
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
// Write job summary
core.summary
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
.addTable([
[{data: 'Category', header: true}, {data: 'Label', header: true}],
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
['Size', sizeLabel]
])
.addRaw(`\n**Files changed:** ${files.length}\n`)
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
await core.summary.write();
-295
View File
@@ -1,295 +0,0 @@
name: PR Labeler
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pr-labeler-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
label:
name: Auto Label PR
runs-on: ubuntu-latest
# Security: Prevent fork PRs from modifying labels (they don't have write access)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Label PR
uses: actions/github-script@v8
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
// ═══════════════════════════════════════════════════════════════
// CONFIGURATION - Single source of truth for all settings
// ═══════════════════════════════════════════════════════════════
const CONFIG = {
// Size thresholds (lines changed)
SIZE_THRESHOLDS: {
XS: 10,
S: 100,
M: 500,
L: 1000
},
// Conventional commit type mappings
TYPE_MAP: Object.freeze({
'feat': 'feature',
'fix': 'bug',
'docs': 'documentation',
'refactor': 'refactor',
'test': 'test',
'ci': 'ci',
'chore': 'chore',
'perf': 'performance',
'style': 'style',
'build': 'build'
}),
// Area detection paths
AREA_PATHS: Object.freeze({
frontend: 'apps/desktop/',
ci: '.github/'
}),
// Label definitions
LABELS: Object.freeze({
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
AREA: ['area/frontend', 'area/ci']
}),
// Pagination
MAX_FILES_PER_PAGE: 100
};
// ═══════════════════════════════════════════════════════════════
// HELPER FUNCTIONS - Small, focused, single responsibility
// ═══════════════════════════════════════════════════════════════
/**
* Safely parse conventional commit type from PR title
* @param {string} title - PR title
* @returns {{type: string|null, isBreaking: boolean}}
*/
function parseConventionalCommit(title) {
if (!title || typeof title !== 'string') {
return { type: null, isBreaking: false };
}
// Limit input length to prevent ReDoS attacks
const safeTitle = title.slice(0, 200);
const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/);
if (!match) {
return { type: null, isBreaking: false };
}
return {
type: match[1].toLowerCase(),
isBreaking: match[3] === '!'
};
}
/**
* Determine size label based on lines changed
* @param {number} totalLines - Total lines changed
* @returns {string} Size label
*/
function determineSizeLabel(totalLines) {
const { SIZE_THRESHOLDS } = CONFIG;
if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS';
if (totalLines < SIZE_THRESHOLDS.S) return 'size/S';
if (totalLines < SIZE_THRESHOLDS.M) return 'size/M';
if (totalLines < SIZE_THRESHOLDS.L) return 'size/L';
return 'size/XL';
}
/**
* Detect areas affected by file changes
* @param {Array} files - List of changed files
* @returns {{frontend: boolean, ci: boolean}}
*/
function detectAreas(files) {
const areas = { frontend: false, ci: false };
const { AREA_PATHS } = CONFIG;
for (const file of files) {
const path = file.filename || '';
if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true;
if (path.startsWith(AREA_PATHS.ci)) areas.ci = true;
}
return areas;
}
/**
* Determine area label based on detected areas
* @param {{frontend: boolean, ci: boolean}} areas
* @returns {string|null} Area label or null
*/
function determineAreaLabel(areas) {
if (areas.frontend) return 'area/frontend';
if (areas.ci) return 'area/ci';
return null;
}
/**
* Remove labels from PR (with error handling)
* @param {Array} labels - Labels to remove
* @param {number} prNumber - PR number
*/
async function removeLabels(labels, prNumber) {
const { owner, repo } = context.repo;
await Promise.allSettled(labels.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
// 404 means label wasn't present - that's fine
if (e.status !== 404) {
console.log(` ⚠ Failed to remove ${label}: ${e.message}`);
}
}
}));
}
/**
* Add labels to PR (with error handling)
* @param {Array} labels - Labels to add
* @param {number} prNumber - PR number
*/
async function addLabels(labels, prNumber) {
if (labels.length === 0) return;
const { owner, repo } = context.repo;
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels
});
console.log(` ✓ Added: ${labels.join(', ')}`);
} catch (e) {
if (e.status === 404) {
core.warning(`One or more labels do not exist. Create them in repository settings.`);
} else {
throw e;
}
}
}
/**
* Fetch PR files with full pagination support
* @param {number} prNumber - PR number
* @returns {Array} List of all files (paginated)
*/
async function fetchPRFiles(prNumber) {
const { owner, repo } = context.repo;
try {
// Use paginate to fetch ALL files, not just first 100
const files = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE }
);
return files;
} catch (e) {
console.log(` ⚠ Could not fetch files: ${e.message}`);
return [];
}
}
// ═══════════════════════════════════════════════════════════════
// MAIN LOGIC - Orchestrates the labeling process
// ═══════════════════════════════════════════════════════════════
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title || '';
console.log(`::group::PR #${prNumber} - Auto-labeling`);
console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`);
console.log(`Action: ${context.payload.action}`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// 1. Parse conventional commit type
const { type, isBreaking } = parseConventionalCommit(title);
if (type && CONFIG.TYPE_MAP[type]) {
labelsToAdd.add(CONFIG.TYPE_MAP[type]);
console.log(` 📝 Type: ${type} → ${CONFIG.TYPE_MAP[type]}`);
} else {
console.log(` ️ No conventional commit prefix detected`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log(` ⚠️ Breaking change detected`);
}
// 2. Detect areas from changed files
const files = await fetchPRFiles(prNumber);
const areas = detectAreas(files);
const areaLabel = determineAreaLabel(areas);
if (areaLabel) {
labelsToAdd.add(areaLabel);
CONFIG.LABELS.AREA.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: ${areaLabel.replace('area/', '')}`);
}
// 3. Calculate size label
const totalLines = (pr.additions || 0) + (pr.deletions || 0);
const sizeLabel = determineSizeLabel(totalLines);
labelsToAdd.add(sizeLabel);
CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`);
console.log('::endgroup::');
// 4. Apply label changes
console.log(`::group::Applying labels`);
// Remove labels that should be replaced (exclude ones we're adding)
const removeList = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
await removeLabels(removeList, prNumber);
// Add new labels
await addLabels([...labelsToAdd], prNumber);
console.log('::endgroup::');
console.log(`✅ PR #${prNumber} labeled successfully`);
// 5. Write job summary
const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none';
const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other';
await core.summary
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
.addTable([
[{ data: 'Category', header: true }, { data: 'Label', header: true }],
['Type', summaryType],
['Area', summaryArea],
['Size', sizeLabel]
])
.addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`)
.write();
+72
View File
@@ -0,0 +1,72 @@
name: PR Status Check
on:
pull_request:
types: [opened, synchronize, reopened]
# Cancel in-progress runs for the same PR
concurrency:
group: pr-status-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
pull-requests: write
jobs:
mark-checking:
name: Set Checking Status
runs-on: ubuntu-latest
# Don't run on fork PRs (they can't write labels)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Update PR status label
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
// Remove old status labels (parallel for speed)
const removePromises = statusLabels.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
});
await Promise.all(removePromises);
// Add checking label
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: ['🔄 Checking']
});
console.log(` ✓ Added: 🔄 Checking`);
} catch (e) {
// Label might not exist - create helpful error
if (e.status === 404) {
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
}
throw e;
}
console.log('::endgroup::');
console.log(`✅ PR #${prNumber} marked as checking`);
+195
View File
@@ -0,0 +1,195 @@
name: PR Status Gate
on:
workflow_run:
workflows: [CI, Lint, Quality Security, Quality DCO, Quality Commit Lint]
types: [completed]
permissions:
pull-requests: write
checks: read
jobs:
update-status:
name: Update PR Status
runs-on: ubuntu-latest
# Only run if this workflow_run is associated with a PR
if: github.event.workflow_run.pull_requests[0] != null
timeout-minutes: 5
steps:
- name: Check all required checks and update label
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.workflow_run.pull_requests[0].number;
const headSha = context.payload.workflow_run.head_sha;
const triggerWorkflow = context.payload.workflow_run.name;
// ═══════════════════════════════════════════════════════════════════════
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
// ═══════════════════════════════════════════════════════════════════════
// Format: "{Workflow Name} / {Job Name} (pull_request)"
//
// To find check names: Go to PR → Checks tab → copy exact name
// To update: Edit this list when workflow jobs are added/renamed/removed
//
// Last validated: 2025-12-26
// ═══════════════════════════════════════════════════════════════════════
const requiredChecks = [
// CI workflow (ci.yml) - 3 checks
'CI / test-frontend (pull_request)',
'CI / test-python (3.12) (pull_request)',
'CI / test-python (3.13) (pull_request)',
// Lint workflow (lint.yml) - 1 check
'Lint / python (pull_request)',
// Quality Security workflow (quality-security.yml) - 4 checks
'Quality Security / CodeQL (javascript-typescript) (pull_request)',
'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)',
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
'Quality Commit Lint / Conventional Commits (pull_request)'
];
const statusLabels = {
checking: '🔄 Checking',
passed: '✅ Ready for Review',
failed: '❌ Checks Failed'
};
console.log(`::group::PR #${prNumber} - Checking required checks`);
console.log(`Triggered by: ${triggerWorkflow}`);
console.log(`Head SHA: ${headSha}`);
console.log(`Required checks: ${requiredChecks.length}`);
console.log('');
// Fetch all check runs for this commit
let allCheckRuns = [];
try {
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: headSha,
per_page: 100
});
allCheckRuns = data.check_runs;
console.log(`Found ${allCheckRuns.length} total check runs`);
} catch (error) {
// Add warning annotation so maintainers are alerted
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`);
console.log(`::error::Failed to fetch check runs: ${error.message}`);
console.log('::endgroup::');
return;
}
let allComplete = true;
let anyFailed = false;
const results = [];
// Check each required check
for (const checkName of requiredChecks) {
const check = allCheckRuns.find(c => c.name === checkName);
if (!check) {
results.push({ name: checkName, status: '⏳ Pending', complete: false });
allComplete = false;
} else if (check.status !== 'completed') {
results.push({ name: checkName, status: '🔄 Running', complete: false });
allComplete = false;
} else if (check.conclusion === 'success') {
results.push({ name: checkName, status: '✅ Passed', complete: true });
} else if (check.conclusion === 'skipped') {
// Skipped checks are treated as passed (e.g., path filters, conditional jobs)
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
} else {
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
anyFailed = true;
}
}
// Print results table
console.log('');
console.log('Check Status:');
console.log('─'.repeat(70));
for (const r of results) {
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
console.log(` ${r.status.padEnd(12)} ${shortName}`);
}
console.log('─'.repeat(70));
console.log('::endgroup::');
// Only update label if all required checks are complete
if (!allComplete) {
const pending = results.filter(r => !r.complete).length;
console.log(`⏳ ${pending}/${requiredChecks.length} checks still pending - keeping current label`);
return;
}
// Determine final label
const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed;
console.log(`::group::Updating PR #${prNumber} label`);
// Remove old status labels
for (const label of Object.values(statusLabels)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
}
// Add final status label
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [newLabel]
});
console.log(` ✓ Added: ${newLabel}`);
} catch (e) {
if (e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`);
}
throw e;
}
console.log('::endgroup::');
// Summary
const passedCount = results.filter(r => r.status === '✅ Passed').length;
const skippedCount = results.filter(r => r.skipped).length;
const failedCount = results.filter(r => r.failed).length;
if (anyFailed) {
console.log(`❌ PR #${prNumber} has ${failedCount} failing check(s)`);
core.summary.addRaw(`## ❌ PR #${prNumber} - Checks Failed\n\n`);
core.summary.addRaw(`**${failedCount}** of **${requiredChecks.length}** required checks failed.\n\n`);
} else {
const skippedNote = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
const totalSuccessful = passedCount + skippedCount;
console.log(`✅ PR #${prNumber} is ready for review (${totalSuccessful}/${requiredChecks.length} checks succeeded${skippedNote})`);
core.summary.addRaw(`## ✅ PR #${prNumber} - Ready for Review\n\n`);
core.summary.addRaw(`All **${requiredChecks.length}** required checks succeeded${skippedNote}.\n\n`);
}
// Add results to summary
core.summary.addTable([
[{data: 'Check', header: true}, {data: 'Status', header: true}],
...results.map(r => [r.name, r.status])
]);
await core.summary.write();
+13 -155
View File
@@ -1,24 +1,15 @@
name: Prepare Release
# Triggers when code is pushed to main (e.g., merging develop → main)
# If package.json version is newer than the latest tag:
# 1. Validates CHANGELOG.md has an entry for this version (FAILS if missing)
# 2. Extracts release notes from CHANGELOG.md
# 3. Creates a new tag which triggers release.yml
# If package.json version is newer than the latest tag, creates a new tag
# which then triggers the release.yml workflow
on:
push:
branches: [main]
paths:
- 'apps/desktop/package.json'
- 'apps/frontend/package.json'
- 'package.json'
workflow_dispatch:
inputs:
force:
description: 'Force release even if version check fails (use with caution)'
required: false
default: false
type: boolean
jobs:
check-and-tag:
@@ -29,28 +20,15 @@ jobs:
should_release: ${{ steps.check.outputs.should_release }}
new_version: ${{ steps.check.outputs.new_version }}
steps:
# Fail fast with clear error if PAT_TOKEN is not configured
- name: Validate PAT_TOKEN is configured
run: |
if [ -z "${{ secrets.PAT_TOKEN }}" ]; then
echo "::error::PAT_TOKEN secret is not configured."
echo "::error::This secret is required for automatic release triggering."
echo "::error::See https://github.com/AndyMik90/Auto-Claude/pull/1043 for setup instructions."
exit 1
fi
# IMPORTANT: Use PAT_TOKEN instead of GITHUB_TOKEN
# When GITHUB_TOKEN pushes a tag, it does NOT trigger other workflows (GitHub security feature)
# PAT_TOKEN allows the tag push to trigger release.yml automatically
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get package version
id: package
run: |
VERSION=$(node -p "require('./apps/desktop/package.json').version")
VERSION=$(node -p "require('./apps/frontend/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: $VERSION"
@@ -74,141 +52,23 @@ jobs:
run: |
PACKAGE_VERSION="${{ steps.package.outputs.version }}"
LATEST_VERSION="${{ steps.latest_tag.outputs.version }}"
FORCE="${{ github.event.inputs.force }}"
echo "Comparing: package=$PACKAGE_VERSION vs latest_tag=$LATEST_VERSION"
# Use npx semver for proper semantic version comparison
# This correctly handles pre-release versions (2.7.3 > 2.7.3-beta.1)
if npx -y semver "$PACKAGE_VERSION" -r ">$LATEST_VERSION" > /dev/null 2>&1; then
# Use sort -V for version comparison
HIGHER=$(printf '%s\n%s' "$PACKAGE_VERSION" "$LATEST_VERSION" | sort -V | tail -n1)
if [ "$HIGHER" = "$PACKAGE_VERSION" ] && [ "$PACKAGE_VERSION" != "$LATEST_VERSION" ]; then
echo "should_release=true" >> $GITHUB_OUTPUT
echo "new_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "✅ New release needed: v$PACKAGE_VERSION"
elif [ "$FORCE" = "true" ]; then
echo "should_release=true" >> $GITHUB_OUTPUT
echo "new_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "⚠️ Force release enabled: v$PACKAGE_VERSION"
else
echo "should_release=false" >> $GITHUB_OUTPUT
echo "⏭️ No release needed (package version not newer than latest tag)"
fi
# CRITICAL: Validate CHANGELOG.md has entry for this version BEFORE creating tag
- name: Validate and extract changelog
if: steps.check.outputs.should_release == 'true'
id: changelog
run: |
VERSION="${{ steps.check.outputs.new_version }}"
CHANGELOG_FILE="CHANGELOG.md"
echo "🔍 Validating CHANGELOG.md for version $VERSION..."
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "::error::CHANGELOG.md not found! Please create CHANGELOG.md with release notes."
exit 1
fi
# Extract changelog section for this version
# Looks for "## X.Y.Z" header and captures until next "## " or "---" or end
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
BEGIN { found=0; content="" }
/^## / {
if (found) exit
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
found=1
# Skip the header line itself, we will add our own
next
}
}
/^---$/ { if (found) exit }
found { content = content $0 "\n" }
END {
if (!found) {
print "NOT_FOUND"
exit 1
}
# Trim leading/trailing whitespace
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
print content
}
' "$CHANGELOG_FILE")
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
echo ""
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error:: CHANGELOG VALIDATION FAILED"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error::"
echo "::error:: Version $VERSION not found in CHANGELOG.md!"
echo "::error::"
echo "::error:: Before releasing, please update CHANGELOG.md with an entry like:"
echo "::error::"
echo "::error:: ## $VERSION - Your Release Title"
echo "::error::"
echo "::error:: ### ✨ New Features"
echo "::error:: - Feature description"
echo "::error::"
echo "::error:: ### 🐛 Bug Fixes"
echo "::error:: - Fix description"
echo "::error::"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo ""
# Also add to job summary for visibility
echo "## ❌ Release Blocked: Missing Changelog" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Version **$VERSION** was not found in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### How to fix:" >> $GITHUB_STEP_SUMMARY
echo "1. Update CHANGELOG.md with release notes for version $VERSION" >> $GITHUB_STEP_SUMMARY
echo "2. Commit and push the changes" >> $GITHUB_STEP_SUMMARY
echo "3. The release will automatically retry" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Expected format:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`markdown" >> $GITHUB_STEP_SUMMARY
echo "## $VERSION - Release Title" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ✨ New Features" >> $GITHUB_STEP_SUMMARY
echo "- Feature description" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🐛 Bug Fixes" >> $GITHUB_STEP_SUMMARY
echo "- Fix description" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "✅ Found changelog entry for version $VERSION"
echo ""
echo "--- Extracted Release Notes ---"
echo "$CHANGELOG_CONTENT"
echo "--- End Release Notes ---"
# Save changelog to file for artifact upload
echo "$CHANGELOG_CONTENT" > changelog-extract.md
# Also save to output (for short changelogs)
# Using heredoc for multiline output
{
echo "content<<CHANGELOG_EOF"
echo "$CHANGELOG_CONTENT"
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
echo "changelog_valid=true" >> $GITHUB_OUTPUT
# Upload changelog as artifact for release.yml to use
- name: Upload changelog artifact
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
uses: actions/upload-artifact@v4
with:
name: changelog-${{ steps.check.outputs.new_version }}
path: changelog-extract.md
retention-days: 1
- name: Create and push tag
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
if: steps.check.outputs.should_release == 'true'
run: |
VERSION="${{ steps.check.outputs.new_version }}"
TAG="v$VERSION"
@@ -225,19 +85,17 @@ jobs:
- name: Summary
run: |
if [ "${{ steps.check.outputs.should_release }}" = "true" ] && [ "${{ steps.changelog.outputs.changelog_valid }}" = "true" ]; then
if [ "${{ steps.check.outputs.should_release }}" = "true" ]; then
echo "## 🚀 Release Triggered" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version:** v${{ steps.check.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ Changelog validated and extracted from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The release workflow has been triggered and will:" >> $GITHUB_STEP_SUMMARY
echo "1. Build binaries for all platforms" >> $GITHUB_STEP_SUMMARY
echo "2. Use changelog from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
echo "2. Generate changelog from PRs" >> $GITHUB_STEP_SUMMARY
echo "3. Create GitHub release" >> $GITHUB_STEP_SUMMARY
echo "4. Update README with new version" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.should_release }}" = "false" ]; then
else
echo "## ⏭️ No Release Needed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Package version:** ${{ steps.package.outputs.version }}" >> $GITHUB_STEP_SUMMARY
+115
View File
@@ -0,0 +1,115 @@
name: Quality Commit Lint
on:
pull_request:
branches: [main, develop]
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
check:
name: Conventional Commits
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate PR title
uses: actions/github-script@v7
with:
retries: 3
script: |
const pr = context.payload.pull_request;
const title = pr.title;
// Sanitize title for safe markdown interpolation (prevent injection)
const sanitizedTitle = title.replace(/`/g, "'").replace(/\[/g, '\\[').replace(/\]/g, '\\]');
console.log(`::group::PR #${pr.number} - Validating PR title`);
console.log(`Title: ${title}`);
// Conventional Commits pattern for PR title
// type(scope)?: description (max 100 chars)
// Optional ! for breaking changes: feat!: or feat(scope)!:
// Scope allows: letters, numbers, hyphens, underscores, slashes, dots
const pattern = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_\-\/\.]+\))?!?: .{1,100}$/;
const isValid = pattern.test(title);
console.log(`Valid: ${isValid}`);
console.log('::endgroup::');
if (!isValid) {
// Log helpful error message to console (visible in workflow logs)
console.log('');
console.log('❌ PR title does not follow Conventional Commits format');
console.log('');
console.log('Expected format: type(scope): description');
console.log('');
console.log('Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert');
console.log('');
console.log('Examples of valid PR titles:');
console.log(' ✓ feat(auth): add OAuth2 login support');
console.log(' ✓ fix(api): handle null response correctly');
console.log(' ✓ docs: update README installation steps');
console.log(' ✓ chore: update dependencies');
console.log('');
console.log(`Your title: "${title}"`);
console.log('');
console.log('Suggested fix for your title:');
// Try to suggest a fix based on the title
const lowerTitle = title.toLowerCase();
const placeholder = '[add description here]';
if (lowerTitle.includes('fix') || lowerTitle.includes('bug')) {
const cleaned = title.replace(/^(fix(ed|es|ing)?|bug)[:\s]*/i, '').trim();
console.log(` → fix: ${cleaned || placeholder}`);
} else if (lowerTitle.includes('add') || lowerTitle.includes('new') || lowerTitle.includes('feature')) {
const cleaned = title.replace(/^(add(ed|s|ing)?|new|features?)[:\s]*/i, '').trim();
console.log(` → feat: ${cleaned || placeholder}`);
} else if (lowerTitle.includes('update') || lowerTitle.includes('change')) {
const cleaned = title.replace(/^(update[ds]?|chang(ed|es|ing)?)[:\s]*/i, '').trim();
console.log(` → chore: ${cleaned || placeholder}`);
} else if (lowerTitle.includes('doc') || lowerTitle.includes('readme')) {
const cleaned = title.replace(/^(docs?|readme)[:\s]*/i, '').trim();
console.log(` → docs: ${cleaned || placeholder}`);
} else {
console.log(` → feat: ${title}`);
console.log(` → fix: ${title}`);
console.log(` → chore: ${title}`);
}
console.log('');
let errorMsg = '## ❌ PR Title Validation Failed\n\n';
errorMsg += `Your PR title does not follow [Conventional Commits](https://www.conventionalcommits.org/) format:\n\n`;
errorMsg += `> \`${sanitizedTitle}\`\n\n`;
errorMsg += '### Expected Format\n\n';
errorMsg += '```\ntype(scope): description\n```\n\n';
errorMsg += '| Type | Description |\n';
errorMsg += '|------|-------------|\n';
errorMsg += '| `feat` | New feature |\n';
errorMsg += '| `fix` | Bug fix |\n';
errorMsg += '| `docs` | Documentation only |\n';
errorMsg += '| `style` | Code style (formatting, etc.) |\n';
errorMsg += '| `refactor` | Code refactoring |\n';
errorMsg += '| `perf` | Performance improvement |\n';
errorMsg += '| `test` | Adding/updating tests |\n';
errorMsg += '| `build` | Build system changes |\n';
errorMsg += '| `ci` | CI/CD changes |\n';
errorMsg += '| `chore` | Maintenance tasks |\n';
errorMsg += '| `revert` | Reverting changes |\n\n';
errorMsg += '### Examples\n\n';
errorMsg += '```\nfeat(auth): add OAuth2 login support\nfix(api/users): handle null response correctly\nfix(package.json): update dependencies\ndocs: update README installation steps\nci: add automated release workflow\n```\n\n';
errorMsg += '### How to Fix\n\n';
errorMsg += 'Edit your PR title to follow the format above.\n';
core.summary.addRaw(errorMsg);
await core.summary.write();
core.setFailed('PR title does not follow Conventional Commits format');
} else {
console.log(`✅ PR title follows Conventional Commits format`);
core.summary
.addHeading('✅ PR Title Valid', 3)
.addRaw(`PR title follows Conventional Commits format: \`${sanitizedTitle}\``);
await core.summary.write();
}
+70
View File
@@ -0,0 +1,70 @@
name: Quality DCO
on:
pull_request:
branches: [main, develop]
permissions:
contents: read
pull-requests: read
jobs:
check:
name: DCO Check
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check DCO Sign-off
uses: dcoapp/dco-check@v1
with:
require-signoff: true
- 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
**Option 1: Sign off your last commit**
\`\`\`bash
git commit --amend --signoff
git push --force-with-lease
\`\`\`
**Option 2: Sign off all commits in this PR**
\`\`\`bash
git rebase HEAD~N --signoff # Replace N with number of commits
git push --force-with-lease
\`\`\`
**Option 3: Configure git to always sign off**
\`\`\`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();
+108 -20
View File
@@ -1,24 +1,14 @@
name: Quality Security
# CodeQL runs on all PRs, pushes to main, and weekly schedule
# Note: CodeQL takes 20-30 min
on:
push:
branches: [main]
paths:
- 'apps/desktop/**'
- 'package.json'
- '.github/workflows/quality-security.yml'
branches: [main, develop]
pull_request:
branches: [main, develop]
paths:
- 'apps/desktop/**'
- 'package.json'
- '.github/workflows/quality-security.yml'
schedule:
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
# Cancel in-progress runs for the same branch/PR
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -36,7 +26,7 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [javascript-typescript]
language: [python, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -55,30 +45,128 @@ jobs:
with:
category: "/language:${{ matrix.language }}"
# --------------------------------------------------------------------------
# Gate Job - Single check for branch protection
# --------------------------------------------------------------------------
python-security:
name: Python Security (Bandit)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Bandit
run: pip install bandit
- name: Run Bandit security scan
id: bandit
run: |
echo "::group::Running Bandit security scan"
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
exit 1
fi
echo "::endgroup::"
- name: Analyze Bandit results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
// Check if report exists
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan may have failed');
return;
}
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
const results = report.results || [];
// Categorize by severity
const high = results.filter(r => r.issue_severity === 'HIGH');
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
const low = results.filter(r => r.issue_severity === 'LOW');
console.log(`::group::Bandit Security Scan Results`);
console.log(`Found ${results.length} issues:`);
console.log(` 🔴 HIGH: ${high.length}`);
console.log(` 🟡 MEDIUM: ${medium.length}`);
console.log(` 🟢 LOW: ${low.length}`);
console.log('');
// Print high severity issues
if (high.length > 0) {
console.log('High Severity Issues:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(` ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
console.log('');
}
}
console.log('::endgroup::');
// Build summary
let summary = `## 🔒 Python Security Scan (Bandit)\n\n`;
summary += `| Severity | Count |\n`;
summary += `|----------|-------|\n`;
summary += `| 🔴 High | ${high.length} |\n`;
summary += `| 🟡 Medium | ${medium.length} |\n`;
summary += `| 🟢 Low | ${low.length} |\n\n`;
if (high.length > 0) {
summary += `### High Severity Issues\n\n`;
for (const issue of high) {
summary += `- **${issue.filename}:${issue.line_number}**\n`;
summary += ` - ${issue.issue_text}\n`;
summary += ` - Test: \`${issue.test_id}\` (${issue.test_name})\n\n`;
}
}
core.summary.addRaw(summary);
await core.summary.write();
// Fail if high severity issues found
if (high.length > 0) {
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('✅ No high severity security issues found');
}
# Summary job that waits for all security checks
security-summary:
name: Security Summary
runs-on: ubuntu-latest
needs: [codeql]
needs: [codeql, python-security]
if: always()
timeout-minutes: 5
steps:
- name: Check security results
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const codeql = '${{ needs.codeql.result }}';
const bandit = '${{ needs.python-security.result }}';
console.log('Security Check Results:');
console.log(` CodeQL: ${codeql}`);
console.log(` Bandit: ${bandit}`);
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters, PR skipping CodeQL)
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
const acceptable = ['success', 'skipped'];
const codeqlOk = acceptable.includes(codeql);
const banditOk = acceptable.includes(bandit);
const allPassed = codeqlOk && banditOk;
if (codeqlOk) {
if (allPassed) {
console.log('\n✅ All security checks passed');
core.summary.addRaw('## ✅ Security Checks Passed\n\nAll security scans completed successfully.');
} else {
+348 -410
View File
@@ -1,10 +1,4 @@
name: Release
# Triggers on version tags (v*) to build and publish releases
#
# IMPORTANT: If branch protection is enabled on 'main', the update-readme job
# requires a PAT or GitHub App token with bypass permissions to push directly.
# Currently uses GITHUB_TOKEN which works if "Allow GitHub Actions to create
# and approve pull requests" is enabled OR branch protection is not configured.
on:
push:
@@ -15,7 +9,7 @@ on:
dry_run:
description: 'Test build without creating release'
required: false
default: false
default: true
type: boolean
jobs:
@@ -23,337 +17,265 @@ jobs:
# Note: macos-15-intel is the last Intel runner, supported until Fall 2027
build-macos-intel:
runs-on: macos-15-intel
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/desktop && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
run: cd apps/frontend && npm run build
- name: Package macOS (Intel)
run: cd apps/desktop && npm run package:mac -- --x64
run: cd apps/frontend && npm run package:mac -- --x64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Notarize macOS Intel app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-intel-builds
path: |
apps/desktop/dist/*.dmg
apps/desktop/dist/*.zip
apps/desktop/dist/*.yml
apps/desktop/dist/*.blockmap
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
# Apple Silicon build on ARM64 runner for native compilation
build-macos-arm64:
runs-on: macos-15
outputs:
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-arm64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-arm64-
- name: Build application
run: cd apps/desktop && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
run: cd apps/frontend && npm run build
- name: Package macOS (Apple Silicon)
run: cd apps/desktop && npm run package:mac -- --arm64
run: cd apps/frontend && npm run package:mac -- --arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Submit notarization (async)
id: notarize
uses: ./.github/actions/submit-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
- name: Notarize macOS ARM64 app
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "$APPLE_ID" ]; then
echo "Skipping notarization: APPLE_ID not configured"
exit 0
fi
cd apps/frontend
for dmg in dist/*.dmg; do
echo "Notarizing $dmg..."
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$dmg"
echo "Successfully notarized and stapled $dmg"
done
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-arm64-builds
path: |
apps/desktop/dist/*.dmg
apps/desktop/dist/*.zip
apps/desktop/dist/*.yml
apps/desktop/dist/*.blockmap
apps/frontend/dist/*.dmg
apps/frontend/dist/*.zip
build-windows:
runs-on: windows-latest
permissions:
id-token: write # Required for OIDC authentication with Azure
contents: read
env:
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
shell: bash
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/desktop && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
run: cd apps/frontend && npm run build
- name: Package Windows
run: cd apps/desktop && npm run package:win
run: cd apps/frontend && npm run package:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
CSC_IDENTITY_AUTO_DISCOVERY: false
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Azure Login (OIDC)
if: env.AZURE_CLIENT_ID != ''
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Windows executable with Azure Trusted Signing
if: env.AZURE_CLIENT_ID != ''
uses: azure/trusted-signing-action@v0.5.11
with:
endpoint: https://neu.codesigning.azure.net/
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
files-folder: apps/desktop/dist
files-folder-filter: exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Verify Windows executable is signed
if: env.AZURE_CLIENT_ID != ''
shell: pwsh
run: |
cd apps/desktop/dist
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
Write-Host "Verifying signature on $($exeFile.Name)..."
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
if ($sig.Status -ne 'Valid') {
Write-Host "::error::Signature verification failed: $($sig.Status)"
Write-Host "::error::Status Message: $($sig.StatusMessage)"
exit 1
}
Write-Host "✅ Signature verified successfully"
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
} else {
Write-Host "::error::No .exe file found to verify"
exit 1
}
- name: Regenerate checksums after signing
if: env.AZURE_CLIENT_ID != ''
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
cd apps/desktop/dist
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
# electron-builder produces one installer exe per build
$exeFiles = Get-ChildItem -Filter "*.exe"
if ($exeFiles.Count -eq 0) {
Write-Host "::error::No .exe files found in dist folder"
exit 1
}
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
$ymlFile = "latest.yml"
if (-not (Test-Path $ymlFile)) {
Write-Host "::error::$ymlFile not found - cannot update checksums"
exit 1
}
$content = Get-Content $ymlFile -Raw
$originalContent = $content
# Process each exe file and update its hash in latest.yml
foreach ($exeFile in $exeFiles) {
Write-Host "Processing $($exeFile.Name)..."
# Compute SHA512 hash and convert to base64 (electron-builder format)
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hashBytes = $sha512.ComputeHash($bytes)
$hash = [System.Convert]::ToBase64String($hashBytes)
$size = $exeFile.Length
Write-Host " Hash: $hash"
Write-Host " Size: $size"
}
# For electron-builder, latest.yml has a single file entry for the installer
# Update the sha512 and size for the primary exe (first one, typically the installer)
$primaryExe = $exeFiles | Select-Object -First 1
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hashBytes = $sha512.ComputeHash($bytes)
$hash = [System.Convert]::ToBase64String($hashBytes)
$size = $primaryExe.Length
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
# Update size
$content = $content -replace 'size: \d+', "size: $size"
if ($content -eq $originalContent) {
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
exit 1
}
Set-Content -Path $ymlFile -Value $content -NoNewline
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
- name: Skip signing notice
if: env.AZURE_CLIENT_ID == ''
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-builds
path: |
apps/desktop/dist/*.exe
apps/desktop/dist/*.yml
apps/desktop/dist/*.blockmap
apps/frontend/dist/*.exe
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Setup Flatpak and verification tools
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder squashfs-tools
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
run: cd apps/frontend && npm ci
- name: Cache bundled Python
uses: actions/cache@v4
with:
path: apps/frontend/python-runtime
key: python-bundle-${{ runner.os }}-x64-3.12.8
restore-keys: |
python-bundle-${{ runner.os }}-x64-
- name: Build application
run: cd apps/desktop && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
run: cd apps/frontend && npm run build
- name: Package Linux
run: cd apps/desktop && npm run package:linux
run: cd apps/frontend && npm run package:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Verify Linux packages
run: cd apps/desktop && npm run verify:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: |
apps/desktop/dist/*.AppImage
apps/desktop/dist/*.deb
apps/desktop/dist/*.flatpak
apps/desktop/dist/*.yml
apps/desktop/dist/*.blockmap
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
finalize-notarization:
needs: [build-macos-intel, build-macos-arm64]
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Download Intel DMG
uses: actions/download-artifact@v7
with:
name: macos-intel-builds
path: intel
- name: Download ARM64 DMG
uses: actions/download-artifact@v7
with:
name: macos-arm64-builds
path: arm64
- name: Wait for notarization and staple
uses: ./.github/actions/finalize-macos-notarization
with:
apple-id: ${{ secrets.APPLE_ID }}
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
- name: Upload stapled Intel DMG
uses: actions/upload-artifact@v4
with:
name: macos-intel-stapled
path: intel/*.dmg
- name: Upload stapled ARM64 DMG
uses: actions/upload-artifact@v4
with:
name: macos-arm64-stapled
path: arm64/*.dmg
apps/frontend/dist/*.AppImage
apps/frontend/dist/*.deb
create-release:
needs: [build-macos-intel, build-macos-arm64, finalize-notarization, build-windows, build-linux]
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -363,80 +285,23 @@ jobs:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
path: dist
- name: Flatten binary artifacts
- name: Flatten and validate artifacts
run: |
mkdir -p release-assets
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) -exec cp {} release-assets/ \;
# Copy stapled macOS DMGs (from finalize-notarization job)
# Validate that stapled DMGs exist before copying
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
else
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
fi
# Copy other macOS artifacts (zip, yml, blockmap) from original build
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Copy Windows and Linux artifacts
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
# Validate that installer files exist
installer_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
if [ "$installer_count" -eq 0 ]; then
echo "::error::No installer artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
# Validate that at least one artifact was copied
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
if [ "$artifact_count" -eq 0 ]; then
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
exit 1
fi
echo "Found $installer_count binary artifact(s):"
find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec basename {} \;
# Merge macOS manifests from Intel and ARM64 builds
# See: https://github.com/electron-userland/electron-builder/issues/5592
- name: Merge macOS manifests
uses: ./.github/actions/merge-macos-manifests
with:
dist-path: dist
output-path: release-assets
copy-other-manifests: 'true'
- name: Validate manifests
run: |
# Validate that electron-updater manifest files are present (required for auto-updates)
yml_count=$(find release-assets -type f -name "*.yml" | wc -l)
if [ "$yml_count" -eq 0 ]; then
echo "::error::No update manifest (.yml) files found! Auto-update will not work."
exit 1
fi
echo "Found $yml_count manifest file(s):"
find release-assets -type f -name "*.yml" -exec basename {} \;
# Validate required manifests exist
missing=""
[ ! -f "release-assets/latest-mac.yml" ] && missing="$missing latest-mac.yml"
[ ! -f "release-assets/latest.yml" ] && missing="$missing latest.yml"
[ ! -f "release-assets/latest-linux.yml" ] && missing="$missing latest-linux.yml"
if [ -n "$missing" ]; then
echo "::error::Missing required manifests:$missing"
echo "::error::Auto-update will fail on affected platforms!"
exit 1
fi
echo ""
echo "All required manifests present:"
echo " - latest-mac.yml (macOS)"
echo " - latest.yml (Windows)"
echo " - latest-linux.yml (Linux)"
echo ""
echo "All release assets:"
echo "Found $artifact_count artifact(s):"
ls -la release-assets/
- name: Generate checksums
@@ -445,6 +310,144 @@ jobs:
sha256sum ./* > checksums.sha256
cat checksums.sha256
- name: Scan with VirusTotal
id: virustotal
continue-on-error: true
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
env:
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
if [ -z "$VT_API_KEY" ]; then
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
echo "vt_results=" >> $GITHUB_OUTPUT
exit 0
fi
echo "## VirusTotal Scan Results" > vt_results.md
echo "" >> vt_results.md
for file in release-assets/*.{exe,dmg,AppImage,deb}; do
[ -f "$file" ] || continue
filename=$(basename "$file")
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
echo "Scanning $filename (${filesize} bytes)..."
# For files > 32MB, get a special upload URL first
if [ "$filesize" -gt 33554432 ]; then
echo " Large file detected, requesting upload URL..."
upload_url_response=$(curl -s --request GET \
--url "https://www.virustotal.com/api/v3/files/upload_url" \
--header "x-apikey: $VT_API_KEY")
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
if [ -z "$upload_url" ]; then
echo "::warning::Failed to get upload URL for large file $filename"
echo "Response: $upload_url_response"
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
continue
fi
api_url="$upload_url"
else
api_url="https://www.virustotal.com/api/v3/files"
fi
# Upload file to VirusTotal
response=$(curl -s --request POST \
--url "$api_url" \
--header "x-apikey: $VT_API_KEY" \
--form "file=@$file")
# Check if response is valid JSON before parsing
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
echo "::warning::VirusTotal returned invalid JSON for $filename"
echo "Response (first 500 chars): ${response:0:500}"
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
continue
fi
# Check for API error response
error_code=$(echo "$response" | jq -r '.error.code // empty')
if [ -n "$error_code" ]; then
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
continue
fi
# Extract analysis ID
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
if [ -z "$analysis_id" ]; then
echo "::warning::Failed to upload $filename to VirusTotal"
echo "Response: $response"
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
continue
fi
echo "Uploaded $filename, analysis ID: $analysis_id"
# Wait for analysis to complete (max 5 minutes per file)
analysis=""
for i in {1..30}; do
sleep 10
analysis=$(curl -s --request GET \
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
--header "x-apikey: $VT_API_KEY")
# Validate JSON response
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
echo " Warning: Invalid JSON response on attempt $i, retrying..."
continue
fi
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
echo " Status: $status (attempt $i/30)"
if [ "$status" = "completed" ]; then
break
fi
done
# Final validation that we have valid analysis data
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
echo "::warning::Could not get complete analysis for $filename, using local hash"
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
continue
fi
# Get file hash for permanent URL
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
if [ -z "$file_hash" ]; then
# Fallback: calculate hash locally
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
fi
# Get detection stats
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
vt_url="https://www.virustotal.com/gui/file/$file_hash"
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
else
echo "$filename is clean ($undetected engines, 0 detections)"
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
fi
done
echo "" >> vt_results.md
# Save results for release notes
cat vt_results.md
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
cat vt_results.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Dry run summary
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}
run: |
@@ -458,127 +461,62 @@ jobs:
cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- name: Extract changelog from CHANGELOG.md
if: ${{ github.event_name == 'push' }}
- name: Generate changelog
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
id: changelog
run: |
# Extract version from tag (v2.7.2 -> 2.7.2)
VERSION=${GITHUB_REF_NAME#v}
CHANGELOG_FILE="CHANGELOG.md"
echo "📋 Extracting release notes for version $VERSION from CHANGELOG.md..."
if [ ! -f "$CHANGELOG_FILE" ]; then
echo "::warning::CHANGELOG.md not found, using minimal release notes"
echo "body=Release v$VERSION" >> $GITHUB_OUTPUT
exit 0
fi
# Extract changelog section for this version
# Looks for "## X.Y.Z" header and captures until next "## " or "---"
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
BEGIN { found=0; content="" }
/^## / {
if (found) exit
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
found=1
next
}
}
/^---$/ { if (found) exit }
found { content = content $0 "\n" }
END {
if (!found) {
print "NOT_FOUND"
exit 0
}
# Trim leading/trailing whitespace
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
print content
}
' "$CHANGELOG_FILE")
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes"
REPO="${{ github.repository }}"
CHANGELOG_CONTENT="Release v$VERSION"$'\n\n'"See [CHANGELOG.md](https://github.com/${REPO}/blob/main/CHANGELOG.md) for details."
fi
echo "✅ Extracted changelog content"
# Save to file first (more reliable for multiline)
echo "$CHANGELOG_CONTENT" > changelog-body.md
# Use file-based output for multiline content
{
echo "body<<CHANGELOG_EOF"
cat changelog-body.md
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
uses: softprops/action-gh-release@v2
with:
body: |
${{ steps.changelog.outputs.body }}
---
**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md
_VirusTotal scan results will be added automatically after release._
${{ steps.virustotal.outputs.vt_results }}
files: release-assets/*
draft: false
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Update README with new version after successful release
update-readme:
needs: [create-release]
runs-on: ubuntu-latest
# Only update README on actual releases (tag push), not dry runs
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
# Use PAT_TOKEN to bypass branch protection rules on main
token: ${{ secrets.PAT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version and detect release type
- name: Extract version from tag
id: version
run: |
# Extract version from tag (v2.7.2 -> 2.7.2)
VERSION=${GITHUB_REF_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
# 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
echo "Updating README to version: $VERSION"
- name: Update README.md
run: |
VERSION="${{ steps.version.outputs.version }}"
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
if [ "$IS_PRERELEASE" = "true" ]; then
node scripts/update-readme.mjs "$VERSION" --prerelease
else
node scripts/update-readme.mjs "$VERSION"
fi
# 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
echo "--- Verifying update ---"
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
# 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
echo "README.md updated to version $VERSION"
grep -E "(version-|Auto-Claude-)" README.md | head -10
- name: Commit and push README update
run: |
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
with:
stale-issue-message: |
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
- If this is still relevant, please comment or update the issue
- If you're working on this, add the `in-progress` label
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
-21
View File
@@ -1,21 +0,0 @@
name: Test Azure Auth
on:
workflow_dispatch:
jobs:
test-auth:
runs-on: windows-latest
permissions:
id-token: write
contents: read
steps:
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Success
run: echo "Azure authentication successful!"
+63
View File
@@ -0,0 +1,63 @@
name: Test on Tag
on:
push:
tags:
- 'v*'
jobs:
# Python tests
test-python:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.12', '3.13']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --tb=short
# Frontend tests
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Run tests
working-directory: apps/frontend
run: npm run test
+71
View File
@@ -0,0 +1,71 @@
name: Validate Version
on:
push:
tags:
- 'v*'
jobs:
validate-version:
name: Validate package.json version matches tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from tag
id: tag_version
run: |
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
echo "Tag version: $TAG_VERSION"
- name: Extract version from package.json
id: package_version
run: |
# Read version from package.json
PACKAGE_VERSION=$(node -p "require('./apps/frontend/package.json').version")
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "Package.json version: $PACKAGE_VERSION"
- name: Compare versions
run: |
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
echo "=========================================="
echo "Version Validation"
echo "=========================================="
echo "Git tag version: v$TAG_VERSION"
echo "package.json version: $PACKAGE_VERSION"
echo "=========================================="
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo ""
echo "❌ ERROR: Version mismatch detected!"
echo ""
echo "The version in package.json ($PACKAGE_VERSION) does not match"
echo "the git tag version ($TAG_VERSION)."
echo ""
echo "To fix this:"
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
echo " 2. Update package.json version to $TAG_VERSION"
echo " 3. Commit the change"
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
echo ""
echo "Or use the automated script:"
echo " node scripts/bump-version.js $TAG_VERSION"
echo ""
exit 1
fi
echo ""
echo "✅ SUCCESS: Versions match!"
echo ""
- name: Version validation result
if: success()
run: |
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
-343
View File
@@ -1,343 +0,0 @@
name: VirusTotal Scan
# Runs AFTER release is published to avoid blocking release creation
# VirusTotal scans can take 5+ minutes per file, which delays releases
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to scan (e.g., v2.8.0)'
required: true
type: string
# Prevent TOCTOU race condition when updating release notes
# If two runs target the same tag, queue them instead of running in parallel
concurrency:
group: virustotal-${{ github.event.inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false
jobs:
scan:
name: Scan release assets
runs-on: ubuntu-latest
permissions:
contents: write # Required to update release notes
steps:
- name: Determine release tag
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
fi
- name: Check for API key
id: check-key
env:
VT_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
if [ -z "$VT_KEY" ]; then
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
echo "has_key=false" >> $GITHUB_OUTPUT
else
echo "has_key=true" >> $GITHUB_OUTPUT
fi
- name: Download release assets
if: steps.check-key.outputs.has_key == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.tag }}"
echo "Downloading assets for release $TAG..."
mkdir -p release-assets
# First verify the release exists
if ! gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "::error::Release $TAG not found"
exit 1
fi
# Download assets, distinguishing between "no matching assets" and real errors
set +e
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.exe" \
--pattern "*.dmg" \
--pattern "*.AppImage" \
--pattern "*.deb" \
--pattern "*.flatpak" \
--dir release-assets 2>&1
exit_code=$?
set -e
if [ $exit_code -ne 0 ]; then
# Check if it's just "no assets matched" vs a real error
asset_count=$(gh release view "$TAG" --repo "${{ github.repository }}" --json assets -q '.assets | length')
if [ "$asset_count" -eq 0 ]; then
echo "Release has no assets yet (this is OK for new releases)"
else
# Check if any scannable assets exist that should have been downloaded
scannable_assets=$(gh release view "$TAG" --repo "${{ github.repository }}" --json assets \
-q '.assets[].name | select(test("\\.(exe|dmg|AppImage|deb|flatpak)$"))' | wc -l)
if [ "$scannable_assets" -gt 0 ]; then
echo "::error::Download failed - $scannable_assets scannable asset(s) exist but download failed"
exit 1
fi
echo "No assets matched the patterns (exe, dmg, AppImage, deb, flatpak)"
fi
fi
echo "Downloaded assets:"
ls -la release-assets/ || echo "No assets found"
- name: Scan with VirusTotal
if: steps.check-key.outputs.has_key == 'true'
id: virustotal
env:
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
run: |
echo "## VirusTotal Scan Results" > vt_results.md
echo "" >> vt_results.md
# Check if there are any files to scan
shopt -s nullglob
files=(release-assets/*.{exe,dmg,AppImage,deb,flatpak})
if [ ${#files[@]} -eq 0 ]; then
echo "No scannable files found in release assets"
echo "- No executable files found in release" >> vt_results.md
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
cat vt_results.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
exit 0
fi
for file in "${files[@]}"; do
[ -f "$file" ] || continue
filename=$(basename "$file")
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
echo "Scanning $filename (${filesize} bytes)..."
# VirusTotal requires special upload URL for files > 32MB
LARGE_FILE_THRESHOLD=33554432 # 32 MB in bytes
if [ "$filesize" -gt "$LARGE_FILE_THRESHOLD" ]; then
echo " Large file detected, requesting upload URL..."
upload_http_response=$(curl -s -w '\n%{http_code}' --request GET \
--url "https://www.virustotal.com/api/v3/files/upload_url" \
--header "x-apikey: $VT_API_KEY")
upload_http_code=$(echo "$upload_http_response" | tail -1)
upload_url_response=$(echo "$upload_http_response" | sed '$d')
if [ "$upload_http_code" != "200" ]; then
echo "::warning::Failed to get upload URL for large file $filename (HTTP $upload_http_code)"
echo "- $filename - ⚠️ Upload failed (large file, HTTP $upload_http_code)" >> vt_results.md
continue
fi
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
if [ -z "$upload_url" ]; then
echo "::warning::Failed to get upload URL for large file $filename"
echo "Response: $upload_url_response"
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
continue
fi
api_url="$upload_url"
else
api_url="https://www.virustotal.com/api/v3/files"
fi
# Upload file to VirusTotal (capture HTTP status code)
http_response=$(curl -s -w '\n%{http_code}' --request POST \
--url "$api_url" \
--header "x-apikey: $VT_API_KEY" \
--form "file=@$file")
http_code=$(echo "$http_response" | tail -1)
response=$(echo "$http_response" | sed '$d')
# Check HTTP status code first
if [ "$http_code" != "200" ]; then
echo "::warning::VirusTotal returned HTTP $http_code for $filename"
if [ "$http_code" = "429" ]; then
echo "- $filename - ⚠️ Scan failed (rate limited)" >> vt_results.md
elif [ "$http_code" = "403" ]; then
echo "- $filename - ⚠️ Scan failed (forbidden - check API key)" >> vt_results.md
else
echo "- $filename - ⚠️ Scan failed (HTTP $http_code)" >> vt_results.md
fi
continue
fi
# Check if response is valid JSON before parsing
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
echo "::warning::VirusTotal returned invalid JSON for $filename"
echo "Response (first 500 chars): ${response:0:500}"
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
continue
fi
# Check for API error response
error_code=$(echo "$response" | jq -r '.error.code // empty')
if [ -n "$error_code" ]; then
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
continue
fi
# Extract analysis ID
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
if [ -z "$analysis_id" ]; then
echo "::warning::Failed to upload $filename to VirusTotal"
echo "Response: $response"
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
continue
fi
echo "Uploaded $filename, analysis ID: $analysis_id"
# Wait for analysis to complete (max 5 minutes per file)
analysis=""
for i in {1..30}; do
sleep 10
analysis_http_response=$(curl -s -w '\n%{http_code}' --request GET \
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
--header "x-apikey: $VT_API_KEY")
analysis_http_code=$(echo "$analysis_http_response" | tail -1)
analysis=$(echo "$analysis_http_response" | sed '$d')
# Check HTTP status code
if [ "$analysis_http_code" != "200" ]; then
echo " Warning: HTTP $analysis_http_code on attempt $i, retrying..."
if [ "$analysis_http_code" = "429" ]; then
echo " Rate limited, waiting longer..."
sleep 30
fi
continue
fi
# Validate JSON response
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
echo " Warning: Invalid JSON response on attempt $i, retrying..."
continue
fi
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
echo " Status: $status (attempt $i/30)"
if [ "$status" = "completed" ]; then
break
fi
done
# Handle analysis timeout - if loop completed without status=completed
if [ "$status" != "completed" ]; then
echo "::warning::Analysis timed out for $filename (status: $status after 5 minutes)"
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis timed out" >> vt_results.md
continue
fi
# Final validation that we have valid analysis data
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
echo "::warning::Could not get complete analysis for $filename, using local hash"
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
continue
fi
# Get file hash for permanent URL
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
if [ -z "$file_hash" ]; then
# Fallback: calculate hash locally
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
fi
# Get detection stats
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
vt_url="https://www.virustotal.com/gui/file/$file_hash"
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
else
echo "$filename is clean ($undetected engines, 0 detections)"
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
fi
done
echo "" >> vt_results.md
# Save results for next step
cat vt_results.md
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
cat vt_results.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Update release notes with scan results
if: steps.check-key.outputs.has_key == 'true' && steps.virustotal.outputs.vt_results != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.tag }}"
# Get current release body with error checking
if ! current_body=$(gh release view "$TAG" --repo "${{ github.repository }}" --json body -q '.body'); then
echo "::error::Failed to fetch current release body for $TAG"
exit 1
fi
# Additional safeguard for empty body
if [ -z "$current_body" ]; then
echo "::warning::Release body is empty, this may indicate a problem"
fi
# Check if VirusTotal results already exist in the body
if echo "$current_body" | grep -q "## VirusTotal Scan Results"; then
echo "VirusTotal results already in release notes, skipping update"
exit 0
fi
# Use file-based approach to avoid shell expansion issues
# First, write current body to file
echo "$current_body" > release-body.md
# Remove placeholder text if present (portable sed approach)
sed '/_VirusTotal scan results will be added automatically after release\./d' release-body.md > release-body.tmp && mv release-body.tmp release-body.md
# Append separator and VT results
echo "" >> release-body.md
echo "---" >> release-body.md
echo "" >> release-body.md
cat vt_results.md >> release-body.md
# Update release using --notes-file to avoid shell quoting issues
gh release edit "$TAG" \
--repo "${{ github.repository }}" \
--notes-file release-body.md
echo "✅ Updated release notes with VirusTotal scan results"
- name: Summary
if: always()
run: |
echo "## VirusTotal Scan Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Release:** ${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.check-key.outputs.has_key }}" = "false" ]; then
echo "⚠️ Scan skipped: VIRUSTOTAL_API_KEY not configured" >> $GITHUB_STEP_SUMMARY
elif [ -f vt_results.md ]; then
cat vt_results.md >> $GITHUB_STEP_SUMMARY
else
echo "No scan results available" >> $GITHUB_STEP_SUMMARY
fi
+47 -22
View File
@@ -7,7 +7,6 @@
Thumbs.db
ehthumbs.db
Desktop.ini
nul
# ===========================
# Security - Environment & Secrets
@@ -15,7 +14,6 @@ nul
.env
.env.*
!.env.example
/config.json
*.pem
*.key
*.crt
@@ -57,8 +55,6 @@ lerna-debug.log*
# Auto Claude Generated
# ===========================
.auto-claude/
.planning/
.planning-archive/
.auto-build-security.json
.auto-claude-security.json
.auto-claude-status
@@ -66,10 +62,51 @@ lerna-debug.log*
.update-metadata.json
# ===========================
# Node.js (apps/desktop)
# Python (apps/backend)
# ===========================
node_modules
apps/desktop/node_modules
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
eggs/
.eggs/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
.venv/
venv/
ENV/
env/
.conda/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
*.py,cover
.hypothesis/
# Type checking
.mypy_cache/
.dmypy.json
dmypy.json
.pytype/
.pyre/
# ===========================
# Node.js (apps/frontend)
# ===========================
node_modules/
.npm
.yarn/
.pnp.*
@@ -78,6 +115,7 @@ apps/desktop/node_modules
dist/
out/
*.tsbuildinfo
apps/frontend/python-runtime/
# Cache
.cache/
@@ -89,8 +127,8 @@ out/
# ===========================
# Electron
# ===========================
apps/desktop/dist/
apps/desktop/out/
apps/frontend/dist/
apps/frontend/out/
*.asar
*.blockmap
*.snap
@@ -110,12 +148,6 @@ test-results/
playwright-report/
playwright/.cache/
# ===========================
# Python
# ===========================
__pycache__/
*.pyc
# ===========================
# Misc
# ===========================
@@ -131,10 +163,3 @@ _bmad-output/
.claude/
/docs
OPUS_ANALYSIS_AND_IDEAS.md
/.github/agents
# Auto Claude generated files
.security-key
/shared_docs
logs/security/
Agents.md
+4 -12
View File
@@ -1,16 +1,12 @@
#!/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
@@ -18,10 +14,8 @@ commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")
# Regex for conventional commits
# 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}$"
# Format: type(optional-scope): description
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?: .{1,100}$"
# Allow merge commits
if echo "$commit_msg" | grep -qE "^Merge "; then
@@ -42,7 +36,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"
@@ -60,8 +54,6 @@ 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 ""
+111 -149
View File
@@ -1,39 +1,5 @@
#!/bin/sh
# =============================================================================
# GIT WORKTREE ENVIRONMENT CLEANUP
# =============================================================================
# Git automatically sets GIT_DIR (and CWD to the working tree root) before
# running hooks -- even in worktrees. We do NOT need to manually parse .git
# files or export GIT_DIR/GIT_WORK_TREE.
#
# However, external tools (IDEs, agents, parent shells) may leave stale
# GIT_DIR/GIT_WORK_TREE values in the environment. If these point to a
# different repo or worktree, git commands in this hook would target the
# wrong repository. Unsetting them lets git re-resolve the correct values
# from the working directory.
# =============================================================================
unset GIT_DIR
unset GIT_WORK_TREE
# =============================================================================
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
# =============================================================================
# core.worktree lives in the SHARED .git/config (not per-worktree). If any
# process accidentally writes it (e.g., running `git init` with a leaked
# GIT_WORK_TREE), ALL repos and worktrees see the wrong working tree root,
# causing files from one worktree to "leak" into others.
#
# This check runs from both main repo and worktree contexts since the config
# is shared and corruption can happen from either.
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
if [ -n "$CORE_WORKTREE" ]; then
echo "Warning: Detected corrupted core.worktree setting ('$CORE_WORKTREE'), removing it..."
if ! git config --unset core.worktree 2>/dev/null; then
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
fi
fi
echo "Running pre-commit checks..."
# =============================================================================
@@ -48,60 +14,34 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
VERSION=$(node -p "require('./package.json').version")
if [ -n "$VERSION" ]; then
# Sync to apps/desktop/package.json
if [ -f "apps/desktop/package.json" ]; then
# Sync to apps/frontend/package.json
if [ -f "apps/frontend/package.json" ]; then
node -e "
const fs = require('fs');
const pkg = require('./apps/desktop/package.json');
const pkg = require('./apps/frontend/package.json');
if (pkg.version !== '$VERSION') {
pkg.version = '$VERSION';
fs.writeFileSync('./apps/desktop/package.json', JSON.stringify(pkg, null, 2) + '\n');
console.log(' Updated apps/desktop/package.json to $VERSION');
fs.writeFileSync('./apps/frontend/package.json', JSON.stringify(pkg, null, 2) + '\n');
console.log(' Updated apps/frontend/package.json to $VERSION');
}
"
git add apps/desktop/package.json
git add apps/frontend/package.json
fi
# Sync to README.md - section-aware updates (stable vs beta)
# Sync to apps/backend/__init__.py
if [ -f "apps/backend/__init__.py" ]; then
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py
rm -f apps/backend/__init__.py.bak
git add apps/backend/__init__.py
echo " Updated apps/backend/__init__.py to $VERSION"
fi
# Sync to README.md
if [ -f "README.md" ]; then
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
# Detect if this is a prerelease (contains - after base version, e.g., 2.7.2-beta.10)
if echo "$VERSION" | grep -q '-'; then
# PRERELEASE: Update only beta sections
echo " Detected PRERELEASE version: $VERSION"
# Update beta version badge (orange)
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
# Update beta version badge link (within BETA_VERSION_BADGE section)
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
perl -i -pe 'if (/<!-- BETA_DOWNLOADS -->/ .. /<!-- BETA_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
done
else
# STABLE: Update stable sections and top badge
echo " Detected STABLE version: $VERSION"
# Update top version badge (blue) - within TOP_VERSION_BADGE section
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable version badge (blue) - within STABLE_VERSION_BADGE section
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
perl -i -pe 'if (/<!-- STABLE_DOWNLOADS -->/ .. /<!-- STABLE_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
done
fi
# 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
rm -f README.md.bak
git add README.md
echo " Updated README.md to $VERSION"
@@ -111,86 +51,108 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
fi
fi
# =============================================================================
# DESKTOP APP CHECKS (TypeScript/React)
# BACKEND CHECKS (Python) - Run first, before frontend
# =============================================================================
# Check if there are staged files in apps/desktop
if git diff --cached --name-only | grep -q "^apps/desktop/"; then
echo "Desktop app changes detected, running checks..."
# Check if there are staged Python files in apps/backend
if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
echo "Python changes detected, running backend checks..."
# Detect if we're in a worktree and check if dependencies are available
IS_WORKTREE=false
DEPS_AVAILABLE=true
if [ -f ".git" ]; then
# .git is a file (not directory) in worktrees
IS_WORKTREE=true
echo "Detected git worktree environment"
# Determine ruff command (venv or global)
RUFF=""
if [ -f "apps/backend/.venv/bin/ruff" ]; then
RUFF="apps/backend/.venv/bin/ruff"
elif [ -f "apps/backend/.venv/Scripts/ruff.exe" ]; then
RUFF="apps/backend/.venv/Scripts/ruff.exe"
elif command -v ruff >/dev/null 2>&1; then
RUFF="ruff"
fi
# Check if node_modules has actual dependencies by looking for a known package
# @lydell/node-pty is required for terminal code and is a common source of TypeScript errors
# It may be in root node_modules (hoisted) or apps/desktop/node_modules
# Note: -d follows symlinks automatically, so this works for both real dirs and symlinks
# We check for the full package path (@lydell/node-pty) rather than just the namespace
# for precise detection - ensures the actual dependency is installed, not just any @lydell package
if [ ! -d "node_modules/@lydell/node-pty" ] && [ ! -d "apps/desktop/node_modules/@lydell/node-pty" ]; then
DEPS_AVAILABLE=false
fi
if [ "$DEPS_AVAILABLE" = false ]; then
if [ "$IS_WORKTREE" = true ]; then
# In worktree without dependencies - warn but allow commit
echo ""
echo "⚠️ WARNING: node_modules not available in this worktree."
echo " TypeScript and lint checks will be skipped."
echo " This is expected for auto-claude worktrees."
echo " Full validation will occur when PR is created/merged."
echo ""
else
# Main repo without dependencies - this is an error
echo "Error: node_modules not found. Run 'npm install' first."
if [ -n "$RUFF" ]; then
# Run ruff linting (auto-fix)
echo "Running ruff lint..."
$RUFF check apps/backend/ --fix
if [ $? -ne 0 ]; then
echo "Ruff lint failed. Please fix Python linting errors before committing."
exit 1
fi
# Run ruff format (auto-fix)
echo "Running ruff format..."
$RUFF format apps/backend/
# Stage any files that were auto-fixed by ruff (POSIX-compliant)
find apps/backend -name "*.py" -type f -exec git add {} + 2>/dev/null || true
else
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
fi
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
echo "Running Python tests..."
cd apps/backend
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py"
if [ -d ".venv" ]; then
# Use venv if it exists
if [ -f ".venv/bin/pytest" ]; then
PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
elif [ -f ".venv/Scripts/pytest.exe" ]; then
# Windows
PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
else
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
fi
else
# Dependencies available - run full frontend checks
# Use subshell to isolate directory changes and prevent worktree corruption
(
cd apps/desktop
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
if [ $? -ne 0 ]; then
echo "lint-staged failed. Please fix linting errors before committing."
exit 1
fi
# Run TypeScript type check (incremental: only rechecks changed files after first run)
echo "Running type check..."
NODE_OPTIONS="--max-old-space-size=2048" npm run typecheck
if [ $? -ne 0 ]; then
echo "Type check failed. Please fix TypeScript errors before committing."
exit 1
fi
# Check for vulnerabilities (only critical severity)
# Note: Using critical level because electron-builder has a known high-severity
# tar vulnerability (CVE-2026-23745) that cannot be fixed until electron-builder
# releases an update with tar@7.x support. This is a build dependency, not runtime.
echo "Checking for vulnerabilities..."
npm audit --audit-level=critical
if [ $? -ne 0 ]; then
echo "Critical severity vulnerabilities found. Run 'npm audit fix' to resolve."
exit 1
fi
)
if [ $? -ne 0 ]; then
exit 1
fi
echo "Frontend checks passed!"
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
fi
if [ $? -ne 0 ]; then
echo "Python tests failed. Please fix failing tests before committing."
exit 1
fi
cd ../..
echo "Backend checks passed!"
fi
# =============================================================================
# FRONTEND CHECKS (TypeScript/React)
# =============================================================================
# Check if there are staged files in apps/frontend
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
echo "Frontend changes detected, running frontend checks..."
cd apps/frontend
# Run lint-staged (handles staged .ts/.tsx files)
npm exec lint-staged
# Run TypeScript type check
echo "Running type check..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "Type check failed. Please fix TypeScript errors before committing."
exit 1
fi
# Run linting
echo "Running lint..."
npm run lint
if [ $? -ne 0 ]; then
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
exit 1
fi
# Check for vulnerabilities (only high severity)
echo "Checking for vulnerabilities..."
npm audit --audit-level=high
if [ $? -ne 0 ]; then
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
exit 1
fi
cd ../..
echo "Frontend checks passed!"
fi
echo "All pre-commit checks passed!"
+42 -92
View File
@@ -1,117 +1,67 @@
repos:
# Version sync - propagate root package.json version to all files
# NOTE: Skip in worktrees - version sync modifies root files which don't exist in worktree
- repo: local
hooks:
- id: version-sync
name: Version Sync
entry: bash
args:
- -c
- |
# Skip in worktrees - .git is a file pointing to main repo, not a directory
# Version sync modifies root-level files that may not exist in worktree context
if [ -f ".git" ]; then
echo "Skipping version-sync in worktree (root files not accessible)"
exit 0
fi
VERSION=$(node -p "require('./package.json').version")
if [ -n "$VERSION" ]; then
# Sync to apps/desktop/package.json
node -e "
const fs = require('fs');
const p = require('./apps/desktop/package.json');
const v = process.argv[1];
if (p.version !== v) {
p.version = v;
fs.writeFileSync('./apps/desktop/package.json', JSON.stringify(p, null, 2) + '\n');
}
" "$VERSION"
# Sync to README.md - section-aware updates (stable vs beta)
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
# Detect if this is a prerelease (contains - after base version)
if echo "$VERSION" | grep -q '-'; then
# PRERELEASE: Update only beta sections
echo " Detected PRERELEASE version: $VERSION"
# Update beta version badge (orange)
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
# Update beta version badge link
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update beta download links (within BETA_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
else
# STABLE: Update stable sections and top badge
echo " Detected STABLE version: $VERSION"
# Update top version badge (blue)
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable version badge (blue)
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
# Update stable download links (within STABLE_DOWNLOADS section only)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
done
fi
rm -f README.md.bak
# Stage changes
git add apps/desktop/package.json README.md 2>/dev/null || true
fi
entry: bash -c '
VERSION=$(node -p "require(\"./package.json\").version");
if [ -n "$VERSION" ]; then
# Sync to apps/frontend/package.json
node -e "const fs=require(\"fs\");const p=require(\"./apps/frontend/package.json\");if(p.version!==\"$VERSION\"){p.version=\"$VERSION\";fs.writeFileSync(\"./apps/frontend/package.json\",JSON.stringify(p,null,2)+\"\n\");}";
# Sync to apps/backend/__init__.py
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak;
# Sync to README.md
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md;
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md && rm -f README.md.bak;
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true;
fi
'
language: system
files: ^package\.json$
pass_filenames: false
# Frontend linting (apps/desktop/) - Biome is 15-25x faster than ESLint
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
# Python linting (apps/backend/)
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.3
hooks:
- id: ruff
args: [--fix]
files: ^apps/backend/
- id: ruff-format
files: ^apps/backend/
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
- repo: local
hooks:
- id: biome
name: Biome (lint + format)
entry: bash
args:
- -c
- |
# Skip in worktrees if node_modules doesn't exist (Biome not installed)
if [ -f ".git" ] && [ ! -d "apps/desktop/node_modules" ]; then
echo "Skipping Biome in worktree (node_modules not found)"
exit 0
fi
cd apps/desktop && npx biome check --write --no-errors-on-unmatched .
- id: pytest
name: Python Tests
entry: bash -c 'cd apps/backend && PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" --ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py'
language: system
files: ^apps/desktop/.*\.(ts|tsx|js|jsx|json)$
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
pass_filenames: false
# Frontend linting (apps/frontend/)
- repo: local
hooks:
- id: eslint
name: ESLint
entry: bash -c 'cd apps/frontend && npm run lint'
language: system
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
pass_filenames: false
- id: typecheck
name: TypeScript Check
entry: bash
args:
- -c
- |
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
if [ -f ".git" ] && [ ! -d "apps/desktop/node_modules" ]; then
echo "Skipping TypeScript check in worktree (node_modules not found)"
exit 0
fi
cd apps/desktop && npm run typecheck
entry: bash -c 'cd apps/frontend && npm run typecheck'
language: system
files: ^apps/desktop/.*\.(ts|tsx)$
files: ^apps/frontend/.*\.(ts|tsx)$
pass_filenames: false
# General checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
-1569
View File
File diff suppressed because it is too large Load Diff
-76
View File
@@ -1,76 +0,0 @@
# 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.*
+420 -298
View File
@@ -1,359 +1,481 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with this repository.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Auto Claude is an autonomous multi-agent coding framework that plans, builds, and validates software for you. It's a TypeScript-first Electron desktop application with a self-contained AI agent layer (Vercel AI SDK v6). A lightweight Python sidecar provides the optional Graphiti memory system.
## Project Overview
> **Deep-dive reference:** [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md) | **Frontend contributing:** [apps/desktop/CONTRIBUTING.md](apps/desktop/CONTRIBUTING.md)
Auto Claude is a multi-agent autonomous coding framework that builds software through coordinated AI agent sessions. It uses the Claude Agent SDK to run agents in isolated workspaces with security controls.
## Product Overview
Auto Claude is a desktop application (+ CLI) where users describe a goal and AI agents autonomously handle planning, implementation, and QA validation. All work happens in isolated git worktrees so the main branch stays safe.
**Core workflow:** User creates a task → Spec creation pipeline assesses complexity and writes a specification → Planner agent breaks it into subtasks → Coder agent implements (can spawn parallel subagents) → QA reviewer validates → QA fixer resolves issues → User reviews and merges.
**Main features:**
- **Autonomous Tasks** — Multi-agent pipeline (planner, coder, QA) that builds features end-to-end
- **Kanban Board** — Visual task management from planning through completion
- **Agent Terminals** — Up to 12 parallel AI-powered terminals with task context injection
- **Insights** — AI chat interface for exploring and understanding your codebase
- **Roadmap** — AI-assisted feature planning with strategic roadmap generation
- **Ideation** — Discover improvements, performance issues, and security vulnerabilities
- **GitHub/GitLab Integration** — Import issues, AI-powered investigation, PR/MR review and creation
- **Changelog** — Generate release notes from completed tasks
- **Memory System** — Graphiti-based knowledge graph retains insights across sessions
- **Isolated Workspaces** — Git worktree isolation for every build; AI-powered semantic merge
- **Flexible Authentication** — Use a Claude Code subscription (OAuth) or API profiles with any Anthropic-compatible endpoint (e.g., Anthropic API, z.ai for GLM models)
- **Multi-Account Swapping** — Register multiple Claude accounts; when one hits a rate limit, Auto Claude automatically switches to an available account
- **Cross-Platform** — Native desktop app for Windows, macOS, and Linux with auto-updates
## Critical Rules
**Vercel AI SDK only** — All AI interactions use the Vercel AI SDK v6 (`ai` package) via the TypeScript agent layer in `apps/desktop/src/main/ai/`. NEVER use `@anthropic-ai/sdk` or `anthropic.Anthropic()` directly. Use `createProvider()` from `ai/providers/factory.ts` and `streamText()`/`generateText()` from the `ai` package. Provider-specific adapters (e.g., `@ai-sdk/anthropic`, `@ai-sdk/openai`) are managed through the provider registry.
**i18n required** — All frontend user-facing text uses `react-i18next` translation keys. Hardcoded strings in JSX/TSX break localization for non-English users. Add keys to both `en/*.json` and `fr/*.json`.
**Platform abstraction** — Never use `process.platform` directly. Import from `apps/desktop/src/main/platform/`. CI tests all three platforms.
**No time estimates** — Provide priority-based ordering instead of duration predictions.
**PR target** — Always target the `develop` branch for PRs, not `main`. Main is reserved for releases.
**No console.log in production code**`console.log` output is invisible in bundled Electron apps. Use Sentry for error tracking in production; reserve `console.log` for development only.
## Work Approach: Orchestrator-First
You are an orchestrator. Your primary role is to understand what needs to be done, break it into workstreams, and delegate execution to agent teams. This keeps your context window focused on coordination and decision-making rather than filling up with implementation details.
<orchestrator_pattern>
When given a task, follow this pattern:
1. **Investigate first** — Read the actual code before forming any hypothesis. Use targeted searches (Glob, Grep, Read) for simple lookups. For broader exploration, spawn an Explore agent.
2. **Plan the approach** — Identify what needs to change, which files are involved, and whether work can be parallelized. For multi-step tasks, create a task list to track workstreams.
3. **Delegate execution** — Spawn agent teams to do the implementation work. Each agent gets a clear, self-contained assignment with all the context it needs: relevant file paths, the specific change to make, and acceptance criteria. Run independent workstreams in parallel.
4. **Verify and integrate** — Review agent outputs, run tests, and ensure changes work together. Fix integration issues or spawn follow-up agents as needed.
</orchestrator_pattern>
**When to delegate vs. do directly:**
- Delegate: multi-file changes, research across the codebase, independent parallel workstreams, tasks that would consume significant context
- Do directly: single-file edits, simple bug fixes, quick lookups, tasks where you already have the context
**Giving agents good assignments** — Each agent works with a fresh context. Include: the specific goal, relevant file paths, code patterns to follow, and what "done" looks like. Agents perform better with explicit, complete instructions than with vague references to "the current task."
**Minimal changes only** — Prefer the simplest approach (e.g., prompt-only changes, single guard clause) before suggesting multi-component solutions. If the user asks for X, implement X — don't bundle additional fixes they didn't request.
**Default to action** — When the user's intent implies making changes, implement them rather than only suggesting. If something is unclear, read the relevant code to fill in the gaps rather than asking. Only ask when genuine ambiguity remains about what the user wants.
## Context Management
Your context window will be automatically compacted as it approaches its limit, allowing you to continue working indefinitely. Do not stop tasks early due to context concerns — instead, persist progress and keep going.
**For long-running tasks:** Use git commits, task lists, and structured notes to track state. When context compacts, review git log and any progress files to re-orient. Focus on incremental progress — complete one component before moving to the next, and commit working states along the way.
**Parallel tool calls** — When reading multiple files, running independent searches, or executing unrelated commands, make all calls in parallel rather than sequentially. This significantly speeds up investigation and implementation.
## Known Gotchas
**Electron path resolution** — For bug fixes in the Electron app, check path resolution differences between dev and production builds (`app.isPackaged`, `process.resourcesPath`). Paths that work in dev often break when Electron is bundled for production — verify both contexts.
### Resetting PR Review State
To fully clear all PR review data so reviews run fresh, delete/reset these three things in `.auto-claude/github/`:
1. `rm .auto-claude/github/pr/logs_*.json` — review log files
2. `rm .auto-claude/github/pr/review_*.json` — review result files
3. Reset `pr/index.json` to `{"reviews": [], "last_updated": null}`
4. Reset `bot_detection_state.json` to `{"reviewed_commits": {}}` — this is the gatekeeper; without clearing it, the bot detector skips already-seen commits
**CRITICAL: All AI interactions use the Claude Agent SDK (`claude-agent-sdk` package), NOT the Anthropic API directly.**
## Project Structure
```
autonomous-coding/
├── apps/
── desktop/ # Electron desktop application (sole app)
├── prompts/ # Agent system prompts (.md)
── src/
├── main/ # Electron main process
│ ├── ai/ # TypeScript AI agent layer (Vercel AI SDK v6)
│ │ ├── providers/ # Multi-provider registry + factory (9+ providers)
│ │ ├── tools/ # Builtin tools (Read, Write, Edit, Bash, Glob, Grep, etc.)
│ │ │ ├── security/ # Bash validator, command parser, path containment
│ │ │ ├── config/ # Agent configs (25+ types), phase config, model resolution
│ │ │ ├── session/ # streamText() agent loop, error classification, progress
│ │ │ ├── agent/ # Worker thread executor + bridge
│ │ │ ├── orchestration/ # Build pipeline (planner → coder → QA)
│ │ │ ├── runners/ # Utility runners (insights, roadmap, PR review, etc.)
│ │ │ ├── mcp/ # MCP client integration
│ │ │ ├── client/ # Client factory convenience constructors
│ │ │ └── auth/ # Token resolution (reuses claude-profile/)
│ │ ├── agent/ # Agent queue, process, state, events
│ │ ├── claude-profile/ # Multi-profile credentials, token refresh, usage
│ │ ├── terminal/ # PTY daemon, lifecycle, Claude integration
│ │ ├── platform/ # Cross-platform abstraction
│ │ ├── ipc-handlers/# 40+ handler modules by domain
│ │ ├── services/ # Session recovery, profile service
│ │ └── changelog/ # Changelog generation and formatting
│ ├── preload/ # Electron preload scripts (electronAPI bridge)
│ ├── renderer/ # React UI
│ │ ├── components/ # UI components (onboarding, settings, task, terminal, github, etc.)
│ │ ├── stores/ # 24+ Zustand state stores
│ │ ├── contexts/ # React contexts (ViewStateContext)
│ │ ├── hooks/ # Custom hooks (useIpc, useTerminal, etc.)
│ │ ├── styles/ # CSS / Tailwind styles
│ │ └── App.tsx # Root component
│ ├── shared/ # Shared types, i18n, constants, utils
│ │ ├── i18n/locales/# en/*.json, fr/*.json
│ │ ├── constants/ # themes.ts, etc.
│ │ ├── types/ # 19+ type definition files
│ │ └── utils/ # ANSI sanitizer, shell escape, provider detection
│ └── types/ # TypeScript type definitions
├── guides/ # Documentation
└── scripts/ # Build and utility scripts
── backend/ # Python backend/CLI - ALL agent logic lives here
├── core/ # Client, auth, security
── agents/ # Agent implementations
├── spec_agents/ # Spec creation agents
│ ├── integrations/ # Graphiti, Linear, GitHub
│ └── prompts/ # Agent system prompts
└── frontend/ # Electron desktop UI
├── guides/ # Documentation
├── tests/ # Test suite
└── scripts/ # Build and utility scripts
```
## Commands Quick Reference
**When working with AI/LLM code:**
- Look in `apps/backend/core/client.py` for the Claude SDK client setup
- Reference `apps/backend/agents/` for working agent implementations
- Check `apps/backend/spec_agents/` for spec creation agent examples
- NEVER use `anthropic.Anthropic()` directly - always use `create_client()` from `core.client`
**Frontend (Electron Desktop App):**
- Built with Electron, React, TypeScript
- AI agents can perform E2E testing using the Electron MCP server
- When bug fixing or implementing features, use the Electron MCP server for automated testing
- See "End-to-End Testing" section below for details
## Commands
### Setup
**Requirements:**
- Python 3.12+ (required for backend)
- Node.js (for frontend)
```bash
npm run install:all # Install all dependencies from root
# Or separately:
cd apps/desktop && npm install
# Install all dependencies from root
npm run install:all
# Or install separately:
# Backend (from apps/backend/)
cd apps/backend && uv venv && uv pip install -r requirements.txt
# Frontend (from apps/frontend/)
cd apps/frontend && npm install
# Set up OAuth token
claude setup-token
# Add to apps/backend/.env: CLAUDE_CODE_OAUTH_TOKEN=your-token
```
### Creating and Running Specs
```bash
cd apps/backend
# Create a spec interactively
python spec_runner.py --interactive
# Create spec from task description
python spec_runner.py --task "Add user authentication"
# Force complexity level (simple/standard/complex)
python spec_runner.py --task "Fix button" --complexity simple
# Run autonomous build
python run.py --spec 001
# List all specs
python run.py --list
```
### Workspace Management
```bash
cd apps/backend
# Review changes in isolated worktree
python run.py --spec 001 --review
# Merge completed build into project
python run.py --spec 001 --merge
# Discard build
python run.py --spec 001 --discard
```
### QA Validation
```bash
cd apps/backend
# Run QA manually
python run.py --spec 001 --qa
# Check QA status
python run.py --spec 001 --qa-status
```
### Testing
```bash
# Install test dependencies (required first time)
cd apps/backend && uv pip install -r ../../tests/requirements-test.txt
| Stack | Command | Tool |
|-------|---------|------|
| Frontend unit | `cd apps/desktop && npm test` | Vitest |
| Frontend E2E | `cd apps/desktop && npm run test:e2e` | Playwright |
# Run all tests (use virtual environment pytest)
apps/backend/.venv/bin/pytest tests/ -v
# Run single test file
apps/backend/.venv/bin/pytest tests/test_security.py -v
# Run specific test
apps/backend/.venv/bin/pytest tests/test_security.py::test_bash_command_validation -v
# Skip slow tests
apps/backend/.venv/bin/pytest tests/ -m "not slow"
# Or from root
npm run test:backend
```
### Spec Validation
```bash
python apps/backend/validate_spec.py --spec-dir apps/backend/specs/001-feature --checkpoint all
```
### Releases
```bash
node scripts/bump-version.js patch|minor|major # Bump version
git push && gh pr create --base main # PR to main triggers release
# 1. Bump version on your branch (creates commit, no tag)
node scripts/bump-version.js patch # 2.8.0 -> 2.8.1
node scripts/bump-version.js minor # 2.8.0 -> 2.9.0
node scripts/bump-version.js major # 2.8.0 -> 3.0.0
# 2. Push and create PR to main
git push origin your-branch
gh pr create --base main
# 3. Merge PR → GitHub Actions automatically:
# - Creates tag
# - Builds all platforms
# - Creates release with changelog
# - Updates README
```
See [RELEASE.md](RELEASE.md) for full release process.
See [RELEASE.md](RELEASE.md) for detailed release process documentation.
## AI Agent Layer (`apps/desktop/src/main/ai/`)
## Architecture
All AI agent logic lives in TypeScript using the Vercel AI SDK v6. This replaces the previous Python `claude-agent-sdk` integration.
### Core Pipeline
### Architecture Overview
**Spec Creation (spec_runner.py)** - Dynamic 3-8 phase pipeline based on task complexity:
- SIMPLE (3 phases): Discovery → Quick Spec → Validate
- STANDARD (6-7 phases): Discovery → Requirements → [Research] → Context → Spec → Plan → Validate
- COMPLEX (8 phases): Full pipeline with Research and Self-Critique phases
- **Provider Layer** (`providers/`) — Multi-provider support via `createProviderRegistry()`. Supports Anthropic, OpenAI, Google, Bedrock, Azure, Mistral, Groq, xAI, and Ollama. Provider-specific transforms handle thinking token normalization and prompt caching.
- **Session Runtime** (`session/`) — `runAgentSession()` uses `streamText()` with `stopWhen: stepCountIs(N)` for agentic tool-use loops. Includes error classification (429/401/400) and progress tracking.
- **Worker Threads** (`agent/`) — Agent sessions run in `worker_threads` to avoid blocking the Electron main process. The `WorkerBridge` relays `postMessage()` events to the existing `AgentManagerEvents` interface.
- **Build Orchestration** (`orchestration/`) — Full planner → coder → QA pipeline. Parallel subagent execution via `Promise.allSettled()`.
- **Tools** (`tools/`) — 8 builtin tools (Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch) defined with Zod schemas via AI SDK `tool()`.
- **Security** (`security/`) — Bash validator, command parser, and path containment ported from Python with identical allowlist behavior.
- **Config** (`config/`) — `AGENT_CONFIGS` registry (25+ agent types), phase-aware model resolution, thinking budgets.
**Implementation (run.py → agent.py)** - Multi-session build:
1. Planner Agent creates subtask-based implementation plan
2. Coder Agent implements subtasks (can spawn subagents for parallel work)
3. QA Reviewer validates acceptance criteria (can perform E2E testing via Electron MCP for frontend changes)
4. QA Fixer resolves issues in a loop (with E2E testing to verify fixes)
### Key Patterns
### Key Components (apps/backend/)
```typescript
// Agent session using streamText()
import { streamText, stepCountIs } from 'ai';
**Core Infrastructure:**
- **core/client.py** - Claude Agent SDK client factory with security hooks and tool permissions
- **core/security.py** - Dynamic command allowlisting based on detected project stack
- **core/auth.py** - OAuth token management for Claude SDK authentication
- **agents/** - Agent implementations (planner, coder, qa_reviewer, qa_fixer)
- **spec_agents/** - Spec creation agents (gatherer, researcher, writer, critic)
const result = streamText({
model: provider,
system: systemPrompt,
messages: conversationHistory,
tools: toolRegistry.getToolsForAgent(agentType),
stopWhen: stepCountIs(1000),
onStepFinish: ({ toolCalls, text, usage }) => {
progressTracker.update(toolCalls, text);
},
});
**Memory & Context:**
- **integrations/graphiti/** - Graphiti memory system (mandatory)
- `queries_pkg/graphiti.py` - Main GraphitiMemory class
- `queries_pkg/client.py` - LadybugDB client wrapper
- `queries_pkg/queries.py` - Graph query operations
- `queries_pkg/search.py` - Semantic search logic
- `queries_pkg/schema.py` - Graph schema definitions
- **graphiti_config.py** - Configuration and validation for Graphiti integration
- **graphiti_providers.py** - Multi-provider factory (OpenAI, Anthropic, Azure, Ollama, Google AI)
- **agents/memory_manager.py** - Session memory orchestration
// Tool definition with Zod schema
import { tool } from 'ai';
import { z } from 'zod';
**Workspace & Security:**
- **cli/worktree.py** - Git worktree isolation for safe feature development
- **context/project_analyzer.py** - Project stack detection for dynamic tooling
- **auto_claude_tools.py** - Custom MCP tools integration
const readTool = tool({
description: 'Read a file from the filesystem',
inputSchema: z.object({
file_path: z.string(),
offset: z.number().optional(),
limit: z.number().optional(),
}),
execute: async ({ file_path, offset, limit }) => { /* ... */ },
});
```
**Integrations:**
- **linear_updater.py** - Optional Linear integration for progress tracking
- **runners/github/** - GitHub Issues & PRs automation
- **Electron MCP** - E2E testing integration for QA agents (Chrome DevTools Protocol)
- Enabled with `ELECTRON_MCP_ENABLED=true` in `.env`
- Allows QA agents to interact with running Electron app
- See "End-to-End Testing" section for details
### Agent Prompts (`apps/desktop/prompts/`)
### Agent Prompts (apps/backend/prompts/)
| Prompt | Purpose |
|--------|---------|
| planner.md | Implementation plan with subtasks |
| coder.md / coder_recovery.md | Subtask implementation / recovery |
| qa_reviewer.md / qa_fixer.md | Acceptance validation / issue fixes |
| spec_gatherer/researcher/writer/critic.md | Spec creation pipeline |
| planner.md | Creates implementation plan with subtasks |
| coder.md | Implements individual subtasks |
| coder_recovery.md | Recovers from stuck/failed subtasks |
| qa_reviewer.md | Validates acceptance criteria |
| qa_fixer.md | Fixes QA-reported issues |
| spec_gatherer.md | Collects user requirements |
| spec_researcher.md | Validates external integrations |
| spec_writer.md | Creates spec.md document |
| spec_critic.md | Self-critique using ultrathink |
| complexity_assessor.md | AI-based complexity assessment |
### Spec Directory Structure
Each spec in `.auto-claude/specs/XXX-name/` contains: `spec.md`, `requirements.json`, `context.json`, `implementation_plan.json`, `qa_report.md`, `QA_FIX_REQUEST.md`
Each spec in `.auto-claude/specs/XXX-name/` contains:
- `spec.md` - Feature specification
- `requirements.json` - Structured user requirements
- `context.json` - Discovered codebase context
- `implementation_plan.json` - Subtask-based plan with status tracking
- `qa_report.md` - QA validation results
- `QA_FIX_REQUEST.md` - Issues to fix (when rejected)
### Memory System (Graphiti)
### Branching & Worktree Strategy
Graph-based semantic memory accessed via a Python MCP sidecar (lives outside `apps/desktop/`). The AI layer connects to it via `createMCPClient` from `@ai-sdk/mcp`. Configured through the Electron app's onboarding/settings UI. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#memory-system) for details.
Auto Claude uses git worktrees for isolated builds. All branches stay LOCAL until user explicitly pushes:
## Frontend Development
### Tech Stack
React 19, TypeScript (strict), Electron 39, Vercel AI SDK v6, Zustand 5, Tailwind CSS v4, Radix UI, xterm.js 6, Vite 7, Vitest 4, Biome 2, Motion (Framer Motion)
### Path Aliases (tsconfig.json)
| Alias | Maps to |
|-------|---------|
| `@/*` | `src/renderer/*` |
| `@shared/*` | `src/shared/*` |
| `@preload/*` | `src/preload/*` |
| `@features/*` | `src/renderer/features/*` |
| `@components/*` | `src/renderer/shared/components/*` |
| `@hooks/*` | `src/renderer/shared/hooks/*` |
| `@lib/*` | `src/renderer/shared/lib/*` |
### State Management (Zustand)
All state lives in `src/renderer/stores/`. Key stores:
- `project-store.ts` — Active project, project list
- `task-store.ts` — Tasks/specs management
- `terminal-store.ts` — Terminal sessions and state
- `settings-store.ts` — User preferences
- `github/issues-store.ts`, `github/pr-review-store.ts` — GitHub integration
- `insights-store.ts`, `roadmap-store.ts`, `kanban-settings-store.ts`
Main process also has stores: `src/main/project-store.ts`, `src/main/terminal-session-store.ts`
### Styling
- **Tailwind CSS v4** with `@tailwindcss/postcss` plugin
- **7 color themes** (Default, Dusk, Lime, Ocean, Retro, Neo + more) defined in `src/shared/constants/themes.ts`
- Each theme has light/dark mode variants via CSS custom properties
- Utility: `clsx` + `tailwind-merge` via `cn()` helper
- Component variants: `class-variance-authority` (CVA)
### IPC Communication
Main ↔ Renderer communication via Electron IPC:
- **Handlers:** `src/main/ipc-handlers/` — organized by domain (github, gitlab, ideation, context, etc.)
- **Preload:** `src/preload/` — exposes safe APIs to renderer
- Pattern: renderer calls via `window.electronAPI.*`, main handles in IPC handler modules
### Agent Management (`src/main/agent/`)
The frontend manages agent lifecycle end-to-end:
- **`agent-queue.ts`** — Queue routing, prioritization, spec number locking
- **`agent-process.ts`** — Spawns worker threads via `WorkerBridge` for agent execution
- **`agent-state.ts`** — Tracks running agent state and status
- **`agent-events.ts`** — Agent lifecycle events and state transitions (structured events from worker threads)
### Claude Profile System (`src/main/claude-profile/`)
Multi-profile credential management for switching between Claude accounts:
- **`credential-utils.ts`** — OS credential storage (Keychain/Windows Credential Manager)
- **`token-refresh.ts`** — OAuth token lifecycle and automatic refresh
- **`usage-monitor.ts`** — API usage tracking and rate limiting per profile
- **`profile-scorer.ts`** — Scores profiles by usage and availability
### Terminal System (`src/main/terminal/`)
Full PTY-based terminal integration:
- **`pty-daemon.ts`** / **`pty-manager.ts`** — Background PTY process management
- **`terminal-lifecycle.ts`** — Session creation, cleanup, event handling
- **`claude-integration-handler.ts`** — Claude SDK integration within terminals
- Renderer: xterm.js 6 with WebGL, fit, web-links, serialize addons. Store: `terminal-store.ts`
## Code Quality
### Frontend
- **Linting:** Biome (`npm run lint` / `npm run lint:fix`)
- **Type checking:** `npm run typecheck` (strict mode)
- **Pre-commit:** Husky + lint-staged runs Biome on staged `.ts/.tsx/.js/.jsx/.json`
- **Testing:** Vitest + React Testing Library + jsdom
## i18n Guidelines
All frontend UI text uses `react-i18next`. Translation files: `apps/desktop/src/shared/i18n/locales/{en,fr}/*.json`
**Namespaces:** `common`, `navigation`, `settings`, `dialogs`, `tasks`, `errors`, `onboarding`, `welcome`
```tsx
import { useTranslation } from 'react-i18next';
const { t } = useTranslation(['navigation', 'common']);
<span>{t('navigation:items.githubPRs')}</span> // CORRECT
<span>GitHub PRs</span> // WRONG
// With interpolation:
<span>{t('errors:task.parseError', { error })}</span>
```
main (user's branch)
└── auto-claude/{spec-name} ← spec branch (isolated worktree)
```
When adding new UI text: add keys to ALL language files, use `namespace:section.key` format.
**Key principles:**
- ONE branch per spec (`auto-claude/{spec-name}`)
- Parallel work uses subagents (agent decides when to spawn)
- NO automatic pushes to GitHub - user controls when to push
- User reviews in spec worktree (`.worktrees/{spec-name}/`)
- Final merge: spec branch → main (after user approval)
## Cross-Platform
**Workflow:**
1. Build runs in isolated worktree on spec branch
2. Agent implements subtasks (can spawn subagents for parallel work)
3. User tests feature in `.worktrees/{spec-name}/`
4. User runs `--merge` to add to their project
5. User pushes to remote when ready
Supports Windows, macOS, Linux. CI tests all three.
### Security Model
**Platform modules:** `apps/desktop/src/main/platform/`
Three-layer defense:
1. **OS Sandbox** - Bash command isolation
2. **Filesystem Permissions** - Operations restricted to project directory
3. **Command Allowlist** - Dynamic allowlist from project analysis (security.py + project_analyzer.py)
| Function | Purpose |
|----------|---------|
| `isWindows()` / `isMacOS()` / `isLinux()` | OS detection |
| `getPathDelimiter()` | `;` (Win) or `:` (Unix) |
| `findExecutable(name)` | Cross-platform executable lookup |
| `requiresShell(command)` | `.cmd/.bat` shell detection (Win) |
Security profile cached in `.auto-claude-security.json`.
Use `findExecutable()` and `joinPaths()` instead of hardcoded paths. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#cross-platform-development) for extended guide.
### Claude Agent SDK Integration
## E2E Testing (Electron MCP)
**CRITICAL: Auto Claude uses the Claude Agent SDK for ALL AI interactions. Never use the Anthropic API directly.**
QA agents can interact with the running Electron app via Chrome DevTools Protocol:
**Client Location:** `apps/backend/core/client.py`
1. Start app: `npm run dev:debug` (debug mode for AI self-validation via Electron MCP)
2. Enable Electron MCP in settings
3. QA runs automatically through the TypeScript agent pipeline
The `create_client()` function creates a configured `ClaudeSDKClient` instance with:
- Multi-layered security (sandbox, permissions, security hooks)
- Agent-specific tool permissions (planner, coder, qa_reviewer, qa_fixer)
- Dynamic MCP server integration based on project capabilities
- Extended thinking token budget control
Tools: `take_screenshot`, `click_by_text`, `fill_input`, `get_page_structure`, `send_keyboard_shortcut`, `eval`. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#end-to-end-testing) for full capabilities.
**Example usage in agents:**
```python
from core.client import create_client
# Create SDK client (NOT raw Anthropic API client)
client = create_client(
project_dir=project_dir,
spec_dir=spec_dir,
model="claude-sonnet-4-5-20250929",
agent_type="coder",
max_thinking_tokens=None # or 5000/10000/16000
)
# Run agent session
response = client.create_agent_session(
name="coder-agent-session",
starting_message="Implement the authentication feature"
)
```
**Why use the SDK:**
- Pre-configured security (sandbox, allowlists, hooks)
- Automatic MCP server integration (Context7, Linear, Graphiti, Electron, Puppeteer)
- Tool permissions based on agent role
- Session management and recovery
- Unified API across all agent types
**Where to find working examples:**
- `apps/backend/agents/planner.py` - Planner agent
- `apps/backend/agents/coder.py` - Coder agent
- `apps/backend/agents/qa_reviewer.py` - QA reviewer
- `apps/backend/agents/qa_fixer.py` - QA fixer
- `apps/backend/spec_agents/` - Spec creation agents
### Memory System
**Graphiti Memory (Mandatory)** - `integrations/graphiti/`
Auto Claude uses Graphiti as its primary memory system with embedded LadybugDB (no Docker required):
- **Graph database with semantic search** - Knowledge graph for cross-session context
- **Session insights** - Patterns, gotchas, discoveries automatically extracted
- **Multi-provider support:**
- LLM: OpenAI, Anthropic, Azure OpenAI, Ollama, Google AI (Gemini)
- Embedders: OpenAI, Voyage AI, Azure OpenAI, Ollama, Google AI
- **Modular architecture:** (`integrations/graphiti/queries_pkg/`)
- `graphiti.py` - Main GraphitiMemory class
- `client.py` - LadybugDB client wrapper
- `queries.py` - Graph query operations
- `search.py` - Semantic search logic
- `schema.py` - Graph schema definitions
**Configuration:**
- Set provider credentials in `apps/backend/.env` (see `.env.example`)
- Required env vars: `GRAPHITI_ENABLED=true`, `ANTHROPIC_API_KEY` or other provider keys
- Memory data stored in `.auto-claude/specs/XXX/graphiti/`
**Usage in agents:**
```python
from integrations.graphiti.memory import get_graphiti_memory
memory = get_graphiti_memory(spec_dir, project_dir)
context = memory.get_context_for_session("Implementing feature X")
memory.add_session_insight("Pattern: use React hooks for state")
```
## Development Guidelines
### Frontend Internationalization (i18n)
**CRITICAL: Always use i18n translation keys for all user-facing text in the frontend.**
The frontend uses `react-i18next` for internationalization. All labels, buttons, messages, and user-facing text MUST use translation keys.
**Translation file locations:**
- `apps/frontend/src/shared/i18n/locales/en/*.json` - English translations
- `apps/frontend/src/shared/i18n/locales/fr/*.json` - French translations
**Translation namespaces:**
- `common.json` - Shared labels, buttons, common terms
- `navigation.json` - Sidebar navigation items, sections
- `settings.json` - Settings page content
- `dialogs.json` - Dialog boxes and modals
- `tasks.json` - Task/spec related content
- `onboarding.json` - Onboarding wizard content
- `welcome.json` - Welcome screen content
**Usage pattern:**
```tsx
import { useTranslation } from 'react-i18next';
// In component
const { t } = useTranslation(['navigation', 'common']);
// Use translation keys, NOT hardcoded strings
<span>{t('navigation:items.githubPRs')}</span> // ✅ CORRECT
<span>GitHub PRs</span> // ❌ WRONG
```
**When adding new UI text:**
1. Add the translation key to ALL language files (at minimum: `en/*.json` and `fr/*.json`)
2. Use `namespace:section.key` format (e.g., `navigation:items.githubPRs`)
3. Never use hardcoded strings in JSX/TSX files
### End-to-End Testing (Electron App)
**IMPORTANT: When bug fixing or implementing new features in the frontend, AI agents can perform automated E2E testing using the Electron MCP server.**
The Electron MCP server allows QA agents to interact with the running Electron app via Chrome DevTools Protocol:
**Setup:**
1. Start the Electron app with remote debugging enabled:
```bash
npm run dev # Already configured with --remote-debugging-port=9222
```
2. Enable Electron MCP in `apps/backend/.env`:
```bash
ELECTRON_MCP_ENABLED=true
ELECTRON_DEBUG_PORT=9222 # Default port
```
**Available Testing Capabilities:**
QA agents (`qa_reviewer` and `qa_fixer`) automatically get access to Electron MCP tools:
1. **Window Management**
- `mcp__electron__get_electron_window_info` - Get info about running windows
- `mcp__electron__take_screenshot` - Capture screenshots for visual verification
2. **UI Interaction**
- `mcp__electron__send_command_to_electron` with commands:
- `click_by_text` - Click buttons/links by visible text
- `click_by_selector` - Click elements by CSS selector
- `fill_input` - Fill form fields by placeholder or selector
- `select_option` - Select dropdown options
- `send_keyboard_shortcut` - Send keyboard shortcuts (Enter, Ctrl+N, etc.)
- `navigate_to_hash` - Navigate to hash routes (#settings, #create, etc.)
3. **Page Inspection**
- `get_page_structure` - Get organized overview of page elements
- `debug_elements` - Get debugging info about buttons and forms
- `verify_form_state` - Check form state and validation
- `eval` - Execute custom JavaScript code
4. **Logging**
- `mcp__electron__read_electron_logs` - Read console logs for debugging
**Example E2E Test Flow:**
```python
# 1. Agent takes screenshot to see current state
agent: "Take a screenshot to see the current UI"
# Uses: mcp__electron__take_screenshot
# 2. Agent inspects page structure
agent: "Get page structure to find available buttons"
# Uses: mcp__electron__send_command_to_electron (command: "get_page_structure")
# 3. Agent clicks a button to navigate
agent: "Click the 'Create New Spec' button"
# Uses: mcp__electron__send_command_to_electron (command: "click_by_text", args: {text: "Create New Spec"})
# 4. Agent fills out a form
agent: "Fill the task description field"
# Uses: mcp__electron__send_command_to_electron (command: "fill_input", args: {placeholder: "Describe your task", value: "Add login feature"})
# 5. Agent submits and verifies
agent: "Click Submit and verify success"
# Uses: click_by_text → take_screenshot → verify result
```
**When to Use E2E Testing:**
- **Bug Fixes**: Reproduce the bug, apply fix, verify it's resolved
- **New Features**: Implement feature, test the UI flow end-to-end
- **UI Changes**: Verify visual changes and interactions work correctly
- **Form Validation**: Test form submission, validation, error handling
**Configuration in `core/client.py`:**
The client automatically enables Electron MCP tools for QA agents when:
- Project is detected as Electron (`is_electron` capability)
- `ELECTRON_MCP_ENABLED=true` is set
- Agent type is `qa_reviewer` or `qa_fixer`
**Note:** Screenshots are automatically compressed (1280x720, quality 60, JPEG) to stay under Claude SDK's 1MB JSON message buffer limit.
## Running the Application
**As a standalone CLI tool**:
```bash
# Desktop app
npm start # Production build + run
npm run dev # Development mode with HMR
npm run dev:debug # Debug mode with verbose output
npm run dev:mcp # Electron MCP server for AI debugging
# Project data: .auto-claude/specs/ (gitignored)
cd apps/backend
python run.py --spec 001
```
**With the Electron frontend**:
```bash
npm start # Build and run desktop app
npm run dev # Run in development mode (includes --remote-debugging-port=9222 for E2E testing)
```
**For E2E Testing with QA Agents:**
1. Start the Electron app: `npm run dev`
2. Enable Electron MCP in `apps/backend/.env`: `ELECTRON_MCP_ENABLED=true`
3. Run QA: `python run.py --spec 001 --qa`
4. QA agents will automatically interact with the running app for testing
**Project data storage:**
- `.auto-claude/specs/` - Per-project data (specs, plans, QA reports, memory) - gitignored
-348
View File
@@ -1,348 +0,0 @@
# Codex Rate Limit Monitoring — Full System Research
> Temporary research file. Delete after implementation.
## Table of Contents
1. [Codex Usage API](#1-codex-usage-api)
2. [Current System Architecture](#2-current-system-architecture)
3. [Anthropic-Hardcoded Locations](#3-anthropic-hardcoded-locations)
4. [Provider-Agnostic Parts (No Changes Needed)](#4-provider-agnostic-parts)
5. [Implementation Plan](#5-implementation-plan)
---
## 1. Codex Usage API
**Sources:** OpenAI Codex source code (`github.com/openai/codex`, Rust codebase), CodexBar macOS app (`github.com/steipete/CodexBar`), Context7 Codex developer docs.
### 1.1 Active Polling Endpoint
```
GET https://chatgpt.com/backend-api/wham/usage
```
Fallback (when base URL doesn't contain `/backend-api`):
```
GET {base_url}/api/codex/usage
```
**Required Headers:**
```http
Authorization: Bearer <access_token>
ChatGPT-Account-Id: <account_id>
Content-Type: application/json
Accept: application/json
```
- `access_token` — The OAuth access token from `auth.openai.com` (same token our `codex-oauth.ts` already obtains)
- `account_id` — Account UUID from OAuth token data. Stored in `~/.codex/auth.json` under `tokens.account_id`. Optional per CodexBar ("when available") but may be required.
### 1.2 Response Schema
From `codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs`:
```json
{
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 96,
"limit_window_seconds": 18000,
"reset_after_seconds": 673,
"reset_at": 1730947200
},
"secondary_window": {
"used_percent": 70,
"limit_window_seconds": 604800,
"reset_after_seconds": 43200,
"reset_at": 1730980800
}
},
"credits": {
"has_credits": false,
"unlimited": true,
"balance": null
},
"additional_rate_limits": [
{
"limit_name": "codex_other",
"metered_feature": "codex_other",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 70,
"limit_window_seconds": 3600,
"reset_after_seconds": 1800,
"reset_at": 1730947200
}
}
}
]
}
```
- `primary_window` = 5h session (18000s). Maps to our `sessionPercent`.
- `secondary_window` = Weekly (604800s = 7d). Maps to our `weeklyPercent`.
- `reset_at` = Unix timestamp (seconds). Convert to ms for our `sessionResetTimestamp`/`weeklyResetTimestamp`.
- `plan_type` values: `guest`, `free`, `go`, `plus`, `pro`, `free_workspace`, `team`, `business`, `education`, `quorum`, `k12`, `enterprise`, `edu`
### 1.3 Passive Headers (From API Responses)
Rate limit data is also returned in HTTP response headers on every `/v1/responses` call:
```
x-codex-primary-used-percent → float (e.g., "25.0")
x-codex-primary-window-minutes → integer (e.g., "300" for 5h)
x-codex-primary-reset-at → unix timestamp seconds
x-codex-secondary-used-percent → float (weekly)
x-codex-secondary-window-minutes → integer
x-codex-secondary-reset-at → unix timestamp seconds
x-codex-credits-has-credits → "true" or "false"
x-codex-credits-unlimited → "true" or "false"
x-codex-credits-balance → decimal string e.g. "9.99"
```
SSE event type `codex.rate_limits` also carries this data inline in streaming responses.
### 1.4 Token Details
Our `codex-oauth.ts` already uses the correct flow:
- **Client ID:** `app_EMoamEEZ73f0CkXaXp7hrann` (same as Codex CLI)
- **Auth endpoint:** `https://auth.openai.com/oauth/authorize`
- **Token endpoint:** `https://auth.openai.com/oauth/token`
- **Scopes:** `openid profile email offline_access`
- **Refresh:** `POST https://auth.openai.com/oauth/token` with `grant_type=refresh_token`
**Missing:** `account_id` for the `ChatGPT-Account-Id` header. Options:
1. Decode from the JWT access token
2. Read from `~/.codex/auth.json` (`tokens.account_id`)
3. Extract during OAuth token exchange (may be in response)
4. Try without it first (optional per CodexBar docs)
---
## 2. Current System Architecture
### 2.1 Two Parallel Account Systems
The app has TWO account management systems that don't fully integrate:
**System A: Legacy Claude Profile Manager (Main Process)**
- `claude-profile-manager.ts` — Manages OAuth profiles, rate limits, usage, auto-swap
- `claude-profiles.json` — Stores profiles with `activeProfileId`, `accountPriorityOrder`
- `usage-monitor.ts` — Polls Anthropic's `/api/oauth/usage` endpoint every 30s
- `token-refresh.ts` — Refreshes tokens via `console.anthropic.com/v1/oauth/token`
- `rate-limit-detector.ts` — Detects rate limits, triggers auto-swap
- `profile-scorer.ts` — Scores profiles by availability for auto-swap
- **100% Anthropic-specific.** Only knows about Anthropic OAuth tokens, Anthropic endpoints, Anthropic keychain format.
**System B: Multi-Provider Accounts (Renderer + Settings)**
- `ProviderAccount[]` in `settings-store.ts` — All connected accounts (any provider)
- `globalPriorityOrder: string[]` in AppSettings — Manual priority queue
- `useActiveProvider()` hook — First account in priority order = active
- **Provider-agnostic.** Works for all 10 providers. But has NO usage monitoring, NO auto-swap.
**The gap:** System A handles usage monitoring + auto-swap but only for Anthropic. System B handles multi-provider accounts but has no usage awareness.
### 2.2 Data Flow: Usage Polling
```
UsageMonitor.start() → 30s interval
checkUsageAndSwap()
├─ determineActiveProfile() ← Hardcoded: defaults to anthropic baseUrl
├─ getCredential() ← Hardcoded: reads from Anthropic keychain
│ └─ ensureValidToken(configDir) ← Hardcoded: refreshes via Anthropic endpoint
├─ fetchUsageViaAPI() ← Hardcoded: only allows anthropic/zai/zhipu domains
│ ├─ getUsageEndpoint(provider) ← Only 3 providers configured
│ ├─ Add anthropic-specific headers ← if (provider === 'anthropic') add beta headers
│ └─ Parse response ← Provider-specific normalization
├─ emit('usage-updated') → IPC 'claude:usageUpdated' → renderer
├─ emit('all-profiles-usage-updated') → IPC 'claude:allProfilesUsageUpdated' → renderer
└─ checkThresholdsExceeded()
└─ performProactiveSwap() ← Only swaps Anthropic profiles
```
### 2.3 Data Flow: Account Swapping
**Manual swap (UI):**
```
User clicks account in UsageIndicator popover
→ handleSwapAccount(accountId)
→ setQueueOrder([accountId, ...rest]) ← Reorders globalPriorityOrder
→ requestUsageUpdate() ← Refreshes usage display
```
**Automatic swap (rate limit hit):**
```
SDK operation fails with 429
→ detectRateLimit(output) ← Pattern: "Limit reached · resets..."
→ recordRateLimitEvent(profileId)
→ getBestAvailableProfileEnv()
→ profileManager.setActiveProfile() ← Only updates claude-profiles.json
→ usageMonitor.getAllProfilesUsage() ← Refreshes UI
← Returns new profile env vars
```
**Problem:** Auto-swap updates `claude-profiles.json` but NOT `globalPriorityOrder`. The renderer's priority queue may be out of sync.
### 2.4 UI Components
| Component | What it shows | Provider-specific? |
|---|---|---|
| `AuthStatusIndicator` | Provider badge (OpenAI/Anthropic) + auth type label | Codex = green "Codex", Anthropic = orange "OAuth" |
| `UsageIndicator` | Usage bars OR "Subscription" OR "Unlimited" | Anthropic OAuth = bars, Codex OAuth = "Subscription", API = "Unlimited" |
| `ProviderAccountCard` | Account card in settings with usage bars | Shows usage bars only when `account.usage` populated (Anthropic only) |
| `ProviderAccountsList` | All accounts grouped by provider | Generic, but re-auth routes differ per provider |
| `AddAccountDialog` | OAuth flow + account creation | Different flows: Codex → `codexAuthLogin()`, Anthropic → `claudeAuthLoginSubprocess()` |
| `ProviderSection` | Provider group with "Add" buttons | Button label: "Add Codex Subscription" vs "Add OAuth" |
### 2.5 Type Naming
Types use "Claude" prefix but are structurally generic:
```typescript
ClaudeUsageSnapshot { sessionPercent, weeklyPercent, resetTimestamps, profileId, ... }
ClaudeUsageData { sessionUsagePercent, weeklyUsagePercent }
ClaudeRateLimitEvent { type, hitAt, resetAt }
ProfileUsageSummary { sessionPercent, weeklyPercent, availabilityScore, ... }
AllProfilesUsage { activeProfile, allProfiles[], fetchedAt }
```
These types work perfectly for Codex data — same session/weekly model. No structural changes needed, just need to populate them.
---
## 3. Anthropic-Hardcoded Locations
### 3.1 CRITICAL — Must Change
| File | Line(s) | What's hardcoded | What to do |
|---|---|---|---|
| `usage-monitor.ts:45-49` | `ALLOWED_USAGE_API_DOMAINS` | Only `api.anthropic.com`, `api.z.ai`, `open.bigmodel.cn` | Add `chatgpt.com` |
| `usage-monitor.ts:60-73` | `PROVIDER_USAGE_ENDPOINTS` | Only anthropic/zai/zhipu paths | Add `{ provider: 'openai', usagePath: '/wham/usage' }` |
| `usage-monitor.ts:662,1069,1346,1359` | `baseUrl: 'https://api.anthropic.com'` | Hardcoded fallback for all OAuth profiles | Detect provider from account, use `chatgpt.com/backend-api` for Codex |
| `usage-monitor.ts:1424` | `if (provider === 'anthropic')` adds beta headers | Anthropic-specific `anthropic-beta` header | Add `else if (provider === 'openai')` to add `ChatGPT-Account-Id` header |
| `token-refresh.ts:31` | `ANTHROPIC_TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token'` | Only Anthropic refresh endpoint | Route to `auth.openai.com/oauth/token` for Codex |
| `token-refresh.ts:37` | `CLAUDE_CODE_CLIENT_ID = '9d1c250a-...'` | Only Anthropic client ID | Use `app_EMoamEEZ73f0CkXaXp7hrann` for Codex |
| `UsageIndicator.tsx:118` | `provider === 'anthropic' && authType === 'oauth'` | Only Anthropic gets usage bars | Add `\|\| provider === 'openai'` |
### 3.2 MODERATE — Should Change
| File | Line(s) | What's hardcoded | What to do |
|---|---|---|---|
| `usage-monitor.ts:1040-1072` | `determineActiveProfile()` | Returns `baseUrl: 'https://api.anthropic.com'` for all OAuth | Detect provider, return `chatgpt.com/backend-api` for Codex |
| `credential-utils.ts` | Keychain service names | `"Claude Code-credentials"` | Codex tokens stored differently (file-based, not keychain) |
| `usage-monitor.ts:1513` | `if (provider === 'zai' \|\| provider === 'zhipu')` | Provider-specific response unwrapping | Add Codex response parsing (different JSON structure) |
| `rate-limit-detector.ts:14` | `RATE_LIMIT_PATTERN` | Claude-specific: `"Limit reached · resets..."` | Add Codex-specific patterns |
| IPC channel names | `'claude:usageUpdated'`, `'claude:allProfilesUsageUpdated'` | "claude" prefix | Cosmetic — rename to `'usage:updated'` etc. (optional, low priority) |
### 3.3 LOW PRIORITY — Nice to Have
| Item | What | Why low priority |
|---|---|---|
| Type naming | `ClaudeUsageSnapshot``UsageSnapshot` | Structural refactor, types work as-is for Codex |
| IPC method names | `requestUsageUpdate` returns `ClaudeUsageSnapshot` | Works fine, just naming |
| `claudeProfileId` on `ProviderAccount` | Only used for Anthropic OAuth | Codex doesn't need it |
---
## 4. Provider-Agnostic Parts
These components already work for any provider and need NO changes:
| Component/Module | Why it's already generic |
|---|---|
| `profile-scorer.ts` | Scores by `billingModel`, usage thresholds, rate limit events — no provider checks |
| `rate-limit-manager.ts` | Stores/checks rate limit events — pure data, no provider logic |
| `operation-registry.ts` | Tracks running operations — no provider awareness |
| `ProviderAccount` type | Has `provider` field, `billingModel`, `usage` — works for any provider |
| `globalPriorityOrder` | Array of account IDs — provider-agnostic ordering |
| `useActiveProvider()` hook | Returns first account in priority order — generic |
| `ProviderAccountCard` | Shows usage bars when `account.usage` is populated — will work for Codex once data flows |
| `AddAccountDialog` | Already has separate Codex OAuth flow |
| `AuthStatusIndicator` | Already shows Codex-specific green badge |
| All i18n keys | Codex-specific labels already exist |
---
## 5. Implementation Plan
### Phase 1: Codex Usage Fetcher (Core)
Create `apps/desktop/src/main/claude-profile/codex-usage-fetcher.ts`:
```typescript
// Responsibilities:
// 1. Read Codex OAuth token (from our codex-auth.json)
// 2. Read account_id (from ~/.codex/auth.json or JWT decode)
// 3. Call GET https://chatgpt.com/backend-api/wham/usage
// 4. Parse response into ClaudeUsageSnapshot format
// 5. Handle 401 → refresh token via codex-oauth.ts
// 6. Handle 403 → mark as needsReauthentication
```
**Key function:**
```typescript
async function fetchCodexUsage(accessToken: string, accountId?: string): Promise<ClaudeUsageSnapshot>
```
### Phase 2: Wire into Usage Monitor
Modify `usage-monitor.ts`:
1. Add `chatgpt.com` to `ALLOWED_USAGE_API_DOMAINS`
2. Add Codex to `PROVIDER_USAGE_ENDPOINTS`
3. Update `determineActiveProfile()` to detect Codex accounts from `globalPriorityOrder`
4. Update `getCredential()` to read Codex OAuth token (from `codex-auth.json`)
5. Update `fetchUsageViaAPI()` to handle Codex response format
6. Add Codex-specific headers (`ChatGPT-Account-Id`)
7. Add Codex response parsing (different JSON structure than Anthropic)
### Phase 3: Token Refresh Routing
Modify `token-refresh.ts` or create parallel Codex path:
- When refreshing a Codex token, use `auth.openai.com/oauth/token` with Codex client ID
- When refreshing an Anthropic token, use `console.anthropic.com/v1/oauth/token` with Claude client ID
- Provider detection: check the account's `provider` field, or detect from token prefix
### Phase 4: UI Updates
1. `UsageIndicator.tsx:118` — Add `|| provider === 'openai'` to `hasUsageMonitoring`
2. That's it — the rest of the UI already handles usage bars, reset times, multi-profile display generically
### Phase 5: Auto-Swap for Codex
1. Add Codex-specific rate limit patterns to `rate-limit-detector.ts`
2. Codex returns `"codexErrorInfo": "UsageLimitExceeded"` on limit hit
3. Auto-swap logic in `profile-scorer.ts` already works — it just needs usage data populated
---
## Appendix: Comparison Table
| Aspect | Anthropic (Claude Code) | OpenAI (Codex) |
|---|---|---|
| **Usage endpoint** | `api.anthropic.com/api/oauth/usage` | `chatgpt.com/backend-api/wham/usage` |
| **Auth header** | `Bearer <oauth_token>` | `Bearer <access_token>` + `ChatGPT-Account-Id` |
| **Session window** | ~5h | Configurable (`limit_window_seconds`) |
| **Weekly window** | 7 days | Configurable (`limit_window_seconds`) |
| **Token source** | Keychain (`Claude Code-credentials`) | File (`codex-auth.json`) |
| **Token refresh** | `console.anthropic.com/v1/oauth/token` | `auth.openai.com/oauth/token` |
| **Client ID** | `9d1c250a-e61b-44d9-88ed-5944d1962f5e` | `app_EMoamEEZ73f0CkXaXp7hrann` |
| **Passive tracking** | Not available | `x-codex-*` response headers |
| **Rate limit error** | `"Limit reached · resets Dec 17..."` | `"codexErrorInfo": "UsageLimitExceeded"` |
| **Profile isolation** | `~/.claude-profiles/{name}/` dirs | Single `codex-auth.json` file |
| **Multi-account** | Multiple config dirs in keychain | Single file (no multi-account yet) |
## Appendix: Caveats
1. **Undocumented API**`chatgpt.com/backend-api/wham/usage` is internal. The Codex CLI depends on it, so it's unlikely to break silently.
2. **Account ID** — May be required. Test without it first. If needed, decode from JWT or read `~/.codex/auth.json`.
3. **CORS** — Not an issue (Electron main process = Node.js).
4. **Polling rate** — Unknown if OpenAI rate-limits `wham/usage`. Start conservatively (every 30-60s).
5. **Multi-account Codex** — Codex CLI doesn't support multiple accounts. We store one token file. If user has multiple Codex accounts, they'd need to re-auth each time (unlike Anthropic which supports multiple config dirs).
+207 -224
View File
@@ -2,41 +2,19 @@
Thank you for your interest in contributing to Auto Claude! This document provides guidelines and instructions for contributing to the project.
## How to Contribute
| What you want to do | Where to start |
|----------------------|----------------|
| Bug fixes & small improvements | Open a PR directly |
| New features / architecture changes | Start a [GitHub Discussion](https://github.com/AndyMik90/Auto-Claude/discussions) or ask in [Discord](https://discord.com/channels/1448614759996854284/1451298184612548779) first |
| Questions & setup help | [Discord #setup-help](https://discord.com/channels/1448614759996854284/1451298184612548779) |
## AI-Assisted Contributions
PRs built with AI tools (Claude, Codex, Copilot, etc.) are welcome here -- given what this project does, it would be odd if they weren't.
That said, we've seen AI-generated PRs that introduce regressions because the contributor didn't verify what the code actually does. To keep quality high, we ask that AI-assisted PRs include the following:
- **Flag it** -- mention AI assistance in the PR description (the PR template has a section for this)
- **State your testing level** -- untested, lightly tested, or fully tested
- **Share context if you can** -- prompts or session logs help reviewers understand intent
- **Confirm you understand the code** -- you should be able to describe what the PR does and how the underlying code works
AI-assisted PRs go through the same review process as any other contribution. Transparency just helps reviewers know where to look more carefully.
## Table of Contents
- [How to Contribute](#how-to-contribute)
- [AI-Assisted Contributions](#ai-assisted-contributions)
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Development Setup](#development-setup)
- [Python Backend](#python-backend)
- [Electron Frontend](#electron-frontend)
- [Running from Source](#running-from-source)
- [Pre-commit Hooks](#pre-commit-hooks)
- [Code Style](#code-style)
- [Testing](#testing)
- [Continuous Integration](#continuous-integration)
- [Git Workflow](#git-workflow)
- [Working with Forks](#working-with-forks)
- [Branch Overview](#branch-overview)
- [Main Branches](#main-branches)
- [Supporting Branches](#supporting-branches)
@@ -45,82 +23,35 @@ AI-assisted PRs go through the same review process as any other contribution. Tr
- [Pull Request Targets](#pull-request-targets)
- [Release Process](#release-process-maintainers)
- [Commit Messages](#commit-messages)
- [PR Hygiene](#pr-hygiene)
- [Pull Request Process](#pull-request-process)
- [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:
- **Node.js 24+** - For the Electron desktop app
- **npm 10+** - Package manager (comes with Node.js)
- **CMake** - Required for building native dependencies (e.g., node-pty)
- **Python 3.12+** - For the backend framework
- **Node.js 24+** - For the Electron frontend
- **npm 10+** - Package manager for the frontend (comes with Node.js)
- **uv** (recommended) or **pip** - Python package manager
- **Git** - Version control
### Installing Node.js 24+
### Installing Python 3.12
**Windows:**
```bash
winget install OpenJS.NodeJS.LTS
winget install Python.Python.3.12
```
**macOS:**
```bash
brew install node@24
brew install python@3.12
```
**Linux (Ubuntu/Debian):**
```bash
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
```
**Linux (Fedora):**
```bash
sudo dnf install nodejs npm
```
### Installing CMake
**Windows:**
```bash
winget install Kitware.CMake
```
**macOS:**
```bash
brew install cmake
```
**Linux (Ubuntu/Debian):**
```bash
sudo apt install cmake
```
**Linux (Fedora):**
```bash
sudo dnf install cmake
sudo apt install python3.12 python3.12-venv
```
## Quick Start
@@ -144,27 +75,95 @@ npm start
## Development Setup
The project is a single Electron desktop application in `apps/desktop/`. All AI agent logic lives in TypeScript using the Vercel AI SDK v6.
The project consists of two main components:
From the repository root:
1. **Python Backend** (`apps/backend/`) - The core autonomous coding framework
2. **Electron Frontend** (`apps/frontend/`) - Optional desktop UI
### Python Backend
The recommended way is to use `npm run install:backend`, but you can also set up manually:
```bash
# Install all dependencies
npm run install:all
# Navigate to the backend directory
cd apps/backend
# Start development mode (hot reload)
npm run dev
# Create virtual environment
# Windows:
py -3.12 -m venv .venv
.venv\Scripts\activate
# macOS/Linux:
python3.12 -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Install test dependencies
pip install -r ../../tests/requirements-test.txt
# Set up environment
cp .env.example .env
# Edit .env and add your CLAUDE_CODE_OAUTH_TOKEN (get it via: claude setup-token)
```
`npm run install:all` installs the npm dependencies for `apps/desktop/`.
### Other Useful Commands
### Electron Frontend
```bash
npm start # Build and run production
npm run build # Build for production
npm run package # Package for distribution
npm test # Run frontend tests
# Navigate to the frontend directory
cd apps/frontend
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Package for distribution
npm run package
```
## Running from Source
If you want to run Auto Claude from source (for development or testing unreleased features), follow these steps:
### Step 1: Clone and Set Up
```bash
git clone https://github.com/AndyMik90/Auto-Claude.git
cd Auto-Claude/apps/backend
# Using uv (recommended)
uv venv && uv pip install -r requirements.txt
# Or using standard Python
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -r requirements.txt
# Set up environment
cd apps/backend
cp .env.example .env
# Edit .env and add your CLAUDE_CODE_OAUTH_TOKEN (get it via: claude setup-token)
```
### Step 2: Run the Desktop UI
```bash
cd ../frontend
# Install dependencies
npm install
# Development mode (hot reload)
npm run dev
# Or production build
npm run build && npm run start
```
<details>
@@ -183,20 +182,28 @@ Auto Claude automatically downloads prebuilt binaries for Windows. If prebuilts
## Pre-commit Hooks
We use Husky + lint-staged to run Biome linting and formatting checks before each commit.
We use [pre-commit](https://pre-commit.com/) to run linting and formatting checks before each commit. This ensures code quality and consistency across the project.
### Setup
Husky is installed automatically when you run `npm install` inside `apps/desktop/`.
```bash
# Install pre-commit
pip install pre-commit
# Install the git hooks (run once after cloning)
pre-commit install
```
### What Runs on Commit
When you commit, the following checks run automatically on staged files:
When you commit, the following checks run automatically:
| Check | Scope | Description |
|-------|-------|-------------|
| **Biome** | `apps/desktop/` | TypeScript/React linter + formatter |
| **typecheck** | `apps/desktop/` | TypeScript type checking |
| **ruff** | `apps/backend/` | Python linter with auto-fix |
| **ruff-format** | `apps/backend/` | Python code formatter |
| **eslint** | `apps/frontend/` | TypeScript/React linter |
| **typecheck** | `apps/frontend/` | TypeScript type checking |
| **trailing-whitespace** | All files | Removes trailing whitespace |
| **end-of-file-fixer** | All files | Ensures files end with newline |
| **check-yaml** | All files | Validates YAML syntax |
@@ -205,29 +212,55 @@ When you commit, the following checks run automatically on staged files:
### Running Manually
```bash
cd apps/desktop
# Run all checks on all files
pre-commit run --all-files
# Run linter (Biome)
npm run lint
# Run a specific hook
pre-commit run ruff --all-files
# Auto-fix lint issues
npm run lint:fix
# Run type checking
npm run typecheck
# Skip hooks temporarily (not recommended)
git commit --no-verify -m "message"
```
### If a Check Fails
1. **Biome auto-fixes**: Run `npm run lint:fix` in `apps/desktop/`. Stage the changes and commit again.
2. **Type errors**: Resolve TypeScript type issues before committing.
1. **Ruff auto-fixes**: Some issues are fixed automatically. Stage the changes and commit again.
2. **ESLint errors**: Fix the reported issues in your code.
3. **Type errors**: Resolve TypeScript type issues before committing.
## Code Style
### Python
- Follow PEP 8 style guidelines
- Use type hints for function signatures
- Use docstrings for public functions and classes
- Keep functions focused and under 50 lines when possible
- Use meaningful variable and function names
```python
# Good
def get_next_chunk(spec_dir: Path) -> dict | None:
"""
Find the next pending chunk in the implementation plan.
Args:
spec_dir: Path to the spec directory
Returns:
The next chunk dict or None if all chunks are complete
"""
...
# Avoid
def gnc(sd):
...
```
### TypeScript/React
- Use TypeScript strict mode
- Follow the existing component patterns in `apps/desktop/src/`
- Follow the existing component patterns in `apps/frontend/src/`
- Use functional components with hooks
- Prefer named exports over default exports
- Use the UI components from `src/renderer/components/ui/`
@@ -254,10 +287,36 @@ export default function(props) {
## Testing
### Python Tests
```bash
# Run all tests (from repository root)
npm run test:backend
# Or manually with pytest
cd apps/backend
.venv/Scripts/pytest.exe ../tests -v # Windows
.venv/bin/pytest ../tests -v # macOS/Linux
# Run a specific test file
npm run test:backend -- tests/test_security.py -v
# Run a specific test
npm run test:backend -- tests/test_security.py::test_bash_command_validation -v
# Skip slow tests
npm run test:backend -- -m "not slow"
# Run with coverage
pytest tests/ --cov=apps/backend --cov-report=html
```
Test configuration is in `tests/pytest.ini`.
### Frontend Tests
```bash
cd apps/desktop
cd apps/frontend
# Run unit tests
npm test
@@ -296,22 +355,30 @@ All pull requests and pushes to `main` trigger automated CI checks via GitHub Ac
| Workflow | Trigger | What it checks |
|----------|---------|----------------|
| **CI** | Push to `main`, PRs | Frontend tests (all 3 platforms), TypeScript type check, build |
| **Lint** | Push to `main`, PRs | Biome (TypeScript/React) |
| **CI** | Push to `main`, PRs | Python tests (3.11 & 3.12), Frontend tests |
| **Lint** | Push to `main`, PRs | Ruff (Python), ESLint + TypeScript (Frontend) |
| **Test on Tag** | Version tags (`v*`) | Full test suite before release |
### PR Requirements
Before a PR can be merged:
1. All CI checks must pass (green checkmarks)
2. Frontend tests pass on all three platforms (Ubuntu, Windows, macOS)
3. Linting passes (no Biome errors)
4. TypeScript type checking passes
2. Python tests pass on both Python 3.11 and 3.12
3. Frontend tests pass
4. Linting passes (no ruff or eslint errors)
5. TypeScript type checking passes
### Running CI Checks Locally
```bash
cd apps/desktop
# Python tests
cd apps/backend
source .venv/bin/activate
pytest ../../tests/ -v
# Frontend tests
cd apps/frontend
npm test
npm run lint
npm run typecheck
@@ -321,72 +388,6 @@ npm run typecheck
We use a **Git Flow** branching strategy to manage releases and parallel development.
### Working with Forks
When contributing to Auto Claude, you'll typically fork the repository first. Proper fork configuration is essential to avoid sync issues.
#### Initial Fork Setup
```bash
# 1. Fork on GitHub (click the Fork button on the repo page)
# 2. Clone YOUR fork (not the original repo)
git clone https://github.com/YOUR-USERNAME/Auto-Claude.git
cd Auto-Claude
# 3. Verify your remotes point to YOUR fork
git remote -v
# Should show:
# origin https://github.com/YOUR-USERNAME/Auto-Claude.git (fetch)
# origin https://github.com/YOUR-USERNAME/Auto-Claude.git (push)
# 4. Add upstream remote to sync with the original repo
git remote add upstream https://github.com/AndyMik90/Auto-Claude.git
```
#### Keeping Your Fork Updated
```bash
# Fetch latest changes from upstream
git fetch upstream
# Sync your develop branch with upstream
git checkout develop
git merge upstream/develop
git push origin develop
```
#### Converting a Fork to Standalone
> ⚠️ **Common Issue:** After making a fork standalone (e.g., disconnecting from the original repo on GitHub), your local git configuration may still reference the original forked repository, causing push/pull issues.
If you convert your fork to a standalone repository:
```bash
# 1. Update origin to point to your standalone repo
git remote set-url origin https://github.com/YOUR-USERNAME/Your-Standalone-Repo.git
# 2. Remove the upstream remote (no longer applicable)
git remote remove upstream
# 3. Verify your configuration
git remote -v
# Should only show your standalone repo as origin
# 4. Update your default branch tracking if needed
git branch --set-upstream-to=origin/main main
git branch --set-upstream-to=origin/develop develop
```
#### Troubleshooting Fork Issues
| Problem | Cause | Solution |
|---------|-------|----------|
| `Permission denied` on push | Origin points to upstream repo | `git remote set-url origin <your-fork-url>` |
| `Repository not found` | Fork was deleted or made standalone | Update remote URL to current repo location |
| Can't push to develop | Local branch tracks wrong remote | `git branch --set-upstream-to=origin/develop` |
| Commits show wrong author | Git config not set | `git config user.email "you@example.com"` |
### Branch Overview
```
@@ -595,40 +596,6 @@ git commit -m "WIP"
- **body**: Detailed explanation if needed (wrap at 72 chars)
- **footer**: Reference issues, breaking changes
### PR Hygiene
**Rebasing:**
- **Rebase onto develop** before opening a PR and before merge to maintain linear history
- Use `git fetch origin && git rebase origin/develop` to sync your branch
- Use `--force-with-lease` when force-pushing rebased branches (safer than `--force`)
- Notify reviewers after force-pushing during active review
- **Exception:** Never rebase after PR is approved and others have reviewed specific commits
**Commit organization:**
- **Squash fixup commits** (typos, "oops", review feedback) into their parent commits
- **Keep logically distinct changes** as separate commits that could be reverted independently
- Each commit should compile and pass tests independently
- No "WIP", "fix tests", or "lint" commits in final PR - squash these
**Before requesting review:**
```bash
# Ensure up-to-date with develop
git fetch origin && git rebase origin/develop
# Clean up commit history (squash fixups, reword messages)
git rebase -i origin/develop
# Force push with safety check
git push --force-with-lease
# Verify everything works
cd apps/desktop && npm test && npm run lint && npm run typecheck
```
**PR size:**
- Keep PRs small (<400 lines changed ideally)
- Split large features into stacked PRs if possible
## Pull Request Process
1. **Fork the repository** and create your branch from `develop` (not main!)
@@ -643,7 +610,11 @@ cd apps/desktop && npm test && npm run lint && npm run typecheck
3. **Test thoroughly**:
```bash
cd apps/desktop && npm test && npm run lint && npm run typecheck
# Python (from repository root)
npm run test:backend
# Frontend
cd apps/frontend && npm test && npm run lint && npm run typecheck
```
4. **Update documentation** if your changes affect:
@@ -681,7 +652,8 @@ When reporting a bug, include:
1. **Clear title** describing the issue
2. **Environment details**:
- OS and version
- Node.js version
- Python version
- Node.js version (for UI issues)
- Auto Claude version
3. **Steps to reproduce** the issue
4. **Expected behavior** vs **actual behavior**
@@ -699,14 +671,25 @@ When requesting a feature:
## Architecture Overview
Auto Claude is a single Electron desktop application in `apps/desktop/`.
Auto Claude consists of two main parts:
### Electron Desktop (`apps/desktop/`)
### Python Backend (`apps/backend/`)
- **AI Agent Layer** (`src/main/ai/`) - Vercel AI SDK v6 agent runtime, providers, tools, security, orchestration
- **Main Process** (`src/main/`) - IPC handlers, agent queue, terminal management, claude-profile
- **Renderer** (`src/renderer/`) - React UI components and Zustand stores
- **Shared** (`src/shared/`) - Types, i18n locales, constants, utilities
The core autonomous coding framework:
- **Entry Points**: `run.py` (build runner), `spec_runner.py` (spec creator)
- **Agent System**: `agent.py`, `client.py`, `prompts/`
- **Execution**: `coordinator.py` (parallel), `worktree.py` (isolation)
- **Memory**: `memory.py` (file-based), `graphiti_memory.py` (graph-based)
- **QA**: `qa_loop.py`, `prompts/qa_*.md`
### Electron Frontend (`apps/frontend/`)
Desktop interface:
- **Main Process**: `src/main/` - Electron main process, IPC handlers
- **Renderer**: `src/renderer/` - React UI components
- **Shared**: `src/shared/` - Types and utilities
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
-2156
View File
File diff suppressed because it is too large Load Diff
+81 -57
View File
@@ -1,54 +1,27 @@
# Aperant (formerly Auto Claude)
# Auto Claude
**Autonomous multi-agent coding framework that plans, builds, and validates software for you.**
![Aperant Kanban Board](.github/assets/Auto-Claude-Kanban.png)
![Auto Claude Kanban Board](.github/assets/Auto-Claude-Kanban.png)
[![Version](https://img.shields.io/badge/version-2.7.2-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/latest)
[![License](https://img.shields.io/badge/license-AGPL--3.0-green?style=flat-square)](./agpl-3.0.txt)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/KCXaPBr4Dj)
[![YouTube](https://img.shields.io/badge/YouTube-Subscribe-FF0000?style=flat-square&logo=youtube&logoColor=white)](https://www.youtube.com/@AndreMikalsen)
[![CI](https://img.shields.io/github/actions/workflow/status/AndyMik90/Auto-Claude/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/AndyMik90/Auto-Claude/actions)
[![Mentioned in Awesome Claude Code](https://awesome.re/mentioned-badge-flat.svg)](https://github.com/hesreallyhim/awesome-claude-code)
---
## Download
### Stable Release
Get the latest pre-built release for your platform:
<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.6-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6)
<!-- STABLE_VERSION_BADGE_END -->
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.6-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.6-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.6-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.6-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-x86_64.flatpak) |
<!-- 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 -->
[![Beta](https://img.shields.io/badge/beta-2.8.0--beta.5-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.8.0-beta.5)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Aperant-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Aperant-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Aperant-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-x64.dmg) |
| **Linux** | [Aperant-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Aperant-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-amd64.deb) |
| **Linux (Flatpak)** | [Aperant-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
| Platform | Download | Notes |
|----------|----------|-------|
| **Windows** | [Auto-Claude-2.7.2.exe](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Installer (NSIS) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | M1/M2/M3 Macs |
| **macOS (Intel)** | [Auto-Claude-2.7.2-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Intel Macs |
| **Linux** | [Auto-Claude-2.7.2.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Universal |
| **Linux (Debian)** | [Auto-Claude-2.7.2.deb](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Ubuntu/Debian |
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
@@ -59,6 +32,7 @@
- **Claude Pro/Max subscription** - [Get one here](https://claude.ai/upgrade)
- **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
- **Git repository** - Your project must be initialized as a git repo
- **Python 3.12+** - Required for the backend and Memory Layer
---
@@ -82,8 +56,6 @@
| **Self-Validating QA** | Built-in quality assurance loop catches issues before you review |
| **AI-Powered Merge** | Automatic conflict resolution when integrating back to main |
| **Memory Layer** | Agents retain insights across sessions for smarter builds |
| **GitHub/GitLab Integration** | Import issues, investigate with AI, create merge requests |
| **Linear Integration** | Sync tasks with Linear for team progress tracking |
| **Cross-Platform** | Native desktop apps for Windows, macOS, and Linux |
| **Auto-Updates** | App updates automatically when new versions are released |
@@ -114,26 +86,86 @@ AI-assisted feature planning with competitor analysis and audience targeting.
## Project Structure
```
Aperant/
Auto-Claude/
├── apps/
── desktop/ # Electron desktop application (TypeScript AI agent layer + UI)
── backend/ # Python agents, specs, QA pipeline
│ └── frontend/ # Electron desktop application
├── guides/ # Additional documentation
├── tests/ # Test suite
└── scripts/ # Build utilities
```
---
## Development
## CLI Usage
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
For headless operation, CI/CD integration, or terminal-only workflows:
For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md).
```bash
cd apps/backend
# Create a spec interactively
python spec_runner.py --interactive
# Run autonomous build
python run.py --spec 001
# Review and merge
python run.py --spec 001 --review
python run.py --spec 001 --merge
```
See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
---
## Configuration
Create `apps/backend/.env` from the example:
```bash
cp apps/backend/.env.example apps/backend/.env
```
| Variable | Required | Description |
|----------|----------|-------------|
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
| `GRAPHITI_ENABLED` | No | Enable Memory Layer for cross-session context |
| `AUTO_BUILD_MODEL` | No | Override the default Claude model |
---
## Building from Source
For contributors and development:
```bash
# Clone the repository
git clone https://github.com/AndyMik90/Auto-Claude.git
cd Auto-Claude
# Install all dependencies
npm run install:all
# Run in development mode
npm run dev
# Or build and run
npm start
```
**System requirements for building:**
- Node.js 24+
- Python 3.12+
- npm 10+
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
---
## Security
Aperant uses a three-layer security model:
Auto Claude uses a three-layer security model:
1. **OS Sandbox** - Bash commands run in isolation
2. **Filesystem Restrictions** - Operations limited to project directory
@@ -150,16 +182,16 @@ All releases are:
| Command | Description |
|---------|-------------|
| `npm run install:all` | Install all dependencies |
| `npm run install:all` | Install backend and frontend dependencies |
| `npm start` | Build and run the desktop app |
| `npm run dev` | Run in development mode with hot reload |
| `npm run package` | Package for current platform |
| `npm run package:mac` | Package for macOS |
| `npm run package:win` | Package for Windows |
| `npm run package:linux` | Package for Linux |
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
| `npm run lint` | Run linter |
| `npm test` | Run frontend tests |
| `npm run test:backend` | Run backend tests |
---
@@ -185,14 +217,6 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
**AGPL-3.0** - GNU Affero General Public License v3.0
Aperant is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
Commercial licensing available for closed-source use cases.
---
## Star History
[![GitHub Repo stars](https://img.shields.io/github/stars/AndyMik90/Auto-Claude?style=social)](https://github.com/AndyMik90/Auto-Claude/stargazers)
[![Star History Chart](https://api.star-history.com/svg?repos=AndyMik90/Auto-Claude&type=Date)](https://star-history.com/#AndyMik90/Auto-Claude&Date)
+27 -92
View File
@@ -66,40 +66,12 @@ node scripts/bump-version.js 2.8.0 # Set specific version
```
This will:
- Update `apps/desktop/package.json`
- Update `apps/frontend/package.json`
- Update `package.json` (root)
- Check if `CHANGELOG.md` has an entry for the new version (warns if missing)
- Update `apps/backend/__init__.py`
- Create a commit with message `chore: bump version to X.Y.Z`
### Step 2: Update CHANGELOG.md (REQUIRED)
**IMPORTANT: The release will fail if CHANGELOG.md doesn't have an entry for the new version.**
Add release notes to `CHANGELOG.md` at the top of the file:
```markdown
## 2.8.0 - Your Release Title
### ✨ New Features
- Feature description
### 🛠️ Improvements
- Improvement description
### 🐛 Bug Fixes
- Fix description
---
```
Then amend the version bump commit:
```bash
git add CHANGELOG.md
git commit --amend --no-edit
```
### Step 3: Push and Create PR
### Step 2: Push and Create PR
```bash
# Push your branch
@@ -109,25 +81,24 @@ git push origin your-branch
gh pr create --base main --title "Release v2.8.0"
```
### Step 4: Merge to Main
### Step 3: Merge to Main
Once the PR is approved and merged to `main`, GitHub Actions will automatically:
1. **Detect the version bump** (`prepare-release.yml`)
2. **Validate CHANGELOG.md** has an entry for the new version (FAILS if missing)
3. **Extract release notes** from CHANGELOG.md
4. **Create a git tag** (e.g., `v2.8.0`)
5. **Trigger the release workflow** (`release.yml`)
6. **Build binaries** for all platforms:
2. **Create a git tag** (e.g., `v2.8.0`)
3. **Trigger the release workflow** (`release.yml`)
4. **Build binaries** for all platforms:
- macOS Intel (x64) - code signed & notarized
- macOS Apple Silicon (arm64) - code signed & notarized
- Windows (NSIS installer) - code signed
- Linux (AppImage + .deb)
7. **Scan binaries** with VirusTotal
8. **Create GitHub release** with release notes from CHANGELOG.md
9. **Update README** with new version badge and download links
5. **Generate changelog** from merged PRs (using release-drafter)
6. **Scan binaries** with VirusTotal
7. **Create GitHub release** with all artifacts
8. **Update README** with new version badge and download links
### Step 5: Verify
### Step 4: Verify
After merging, check:
- [GitHub Actions](https://github.com/AndyMik90/Auto-Claude/actions) - ensure all workflows pass
@@ -142,49 +113,29 @@ We follow [Semantic Versioning](https://semver.org/):
- **MINOR** (0.X.0): New features, backwards compatible
- **PATCH** (0.0.X): Bug fixes, backwards compatible
## Changelog Management
## Changelog Generation
Release notes are managed in `CHANGELOG.md` and used for GitHub releases.
Changelogs are automatically generated from merged PRs using [Release Drafter](https://github.com/release-drafter/release-drafter).
### Changelog Format
### PR Labels for Changelog Categories
Each version entry in `CHANGELOG.md` should follow this format:
| Label | Category |
|-------|----------|
| `feature`, `enhancement` | New Features |
| `bug`, `fix` | Bug Fixes |
| `improvement`, `refactor` | Improvements |
| `documentation` | Documentation |
| (any other) | Other Changes |
```markdown
## X.Y.Z - Release Title
### ✨ New Features
- Feature description with context
### 🛠️ Improvements
- Improvement description
### 🐛 Bug Fixes
- Fix description
---
```
### Changelog Validation
The release workflow **validates** that `CHANGELOG.md` has an entry for the version being released:
- If the entry is **missing**, the release is **blocked** with a clear error message
- If the entry **exists**, its content is used for the GitHub release notes
### Writing Good Release Notes
- **Be specific**: Instead of "Fixed bug", write "Fixed crash when opening large files"
- **Group by impact**: Features first, then improvements, then fixes
- **Credit contributors**: Mention contributors for significant changes
- **Link issues**: Reference GitHub issues where relevant (e.g., "Fixes #123")
**Tip:** Add appropriate labels to your PRs for better changelog organization.
## Workflows
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag |
| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release |
| `prepare-release.yml` | Push to `main` | Detects version bump, creates tag |
| `release.yml` | Tag `v*` pushed | Builds binaries, creates release |
| `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json |
| `update-readme` (in release.yml) | After release | Updates README with new version |
## Troubleshooting
@@ -194,7 +145,7 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
1. Check if version in `package.json` is greater than latest tag:
```bash
git tag -l 'v*' --sort=-version:refname | head -1
cat apps/desktop/package.json | grep version
cat apps/frontend/package.json | grep version
```
2. Ensure the merge commit touched `package.json`:
@@ -202,22 +153,6 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
git diff HEAD~1 --name-only | grep package.json
```
### Release blocked: Missing changelog entry
If you see "CHANGELOG VALIDATION FAILED" in the workflow:
1. The `prepare-release.yml` workflow validated that `CHANGELOG.md` doesn't have an entry for the new version
2. **Fix**: Add an entry to `CHANGELOG.md` with the format `## X.Y.Z - Title`
3. Commit and push the changelog update
4. The workflow will automatically retry when the changes are pushed to `main`
```bash
# Add changelog entry, then:
git add CHANGELOG.md
git commit -m "docs: add changelog for vX.Y.Z"
git push origin main
```
### Build failed after tag was created
- The release won't be published if builds fail
View File
+339
View File
@@ -0,0 +1,339 @@
# Auto Claude Environment Variables
# Copy this file to .env and fill in your values
# =============================================================================
# AUTHENTICATION (REQUIRED)
# =============================================================================
# Auto Claude uses Claude Code OAuth authentication.
# Direct API keys (ANTHROPIC_API_KEY) are NOT supported to prevent silent billing.
#
# Option 1: Run `claude setup-token` to save token to macOS Keychain (recommended)
# Option 2: Set the token explicitly:
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
#
# For enterprise/proxy setups (CCR):
# ANTHROPIC_AUTH_TOKEN=sk-zcf-x-ccr
# =============================================================================
# CUSTOM API ENDPOINT (OPTIONAL)
# =============================================================================
# Override the default Anthropic API endpoint. Useful for:
# - Local proxies (ccr, litellm)
# - API gateways
# - Self-hosted Claude instances
#
# ANTHROPIC_BASE_URL=http://127.0.0.1:3456
#
# Related settings (usually set together with ANTHROPIC_BASE_URL):
# NO_PROXY=127.0.0.1
# DISABLE_TELEMETRY=true
# DISABLE_COST_WARNINGS=true
# API_TIMEOUT_MS=600000
# Model override (OPTIONAL)
# Default: claude-opus-4-5-20251101
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
# =============================================================================
# GIT/WORKTREE SETTINGS (OPTIONAL)
# =============================================================================
# Configure how Auto Claude handles git worktrees for isolated builds.
# Default base branch for worktree creation (OPTIONAL)
# If not set, Auto Claude will auto-detect main/master, or fall back to current branch.
# Common values: main, master, develop
# DEFAULT_BRANCH=main
# =============================================================================
# DEBUG MODE (OPTIONAL)
# =============================================================================
# Enable debug logging for development and troubleshooting.
# Shows detailed information about runner execution, agent calls, file operations.
# Enable debug mode (default: false)
# DEBUG=true
# Debug log level: 1=basic, 2=detailed, 3=verbose (default: 1)
# DEBUG_LEVEL=1
# Log to file instead of stdout (OPTIONAL)
# DEBUG_LOG_FILE=auto-claude/debug.log
# =============================================================================
# LINEAR INTEGRATION (OPTIONAL)
# =============================================================================
# Enable Linear integration for real-time progress tracking in Linear.
# Get your API key from: https://linear.app/YOUR-TEAM/settings/api
# Linear API Key (OPTIONAL - enables Linear integration)
# LINEAR_API_KEY=lin_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Pre-configured Team ID (OPTIONAL - will auto-detect if not set)
# LINEAR_TEAM_ID=
# Pre-configured Project ID (OPTIONAL - will create project if not set)
# LINEAR_PROJECT_ID=
# =============================================================================
# UI SETTINGS (OPTIONAL)
# =============================================================================
# Enable fancy terminal UI with icons, colors, and interactive menus.
# Set to "false" to use plain text output (useful for CI/CD or log files).
# Enable fancy UI (default: true)
# ENABLE_FANCY_UI=true
# =============================================================================
# ELECTRON MCP SERVER (OPTIONAL)
# =============================================================================
# Enable Electron MCP server for AI agents to interact with and validate
# Electron desktop applications. This allows QA agents to capture screenshots,
# inspect windows, and validate Electron apps during the review process.
#
# The electron-mcp-server connects via Chrome DevTools Protocol to an Electron
# app running with remote debugging enabled.
#
# Prerequisites:
# 1. Start your Electron app with remote debugging:
# ./YourElectronApp --remote-debugging-port=9222
#
# 2. For auto-claude-ui specifically (use the MCP-enabled scripts):
# cd auto-claude-ui
# pnpm run dev:mcp # Development mode with MCP debugging
# # OR for production build:
# pnpm run start:mcp # Production mode with MCP debugging
#
# Note: Only QA agents (qa_reviewer, qa_fixer) receive Electron MCP tools.
# Coder and Planner agents do NOT have access to these tools to minimize
# context token usage and keep agents focused on their roles.
#
# See: https://github.com/anthropics/anthropic-quickstarts/tree/main/mcp-electron-demo
# Enable Electron MCP integration (default: false)
# ELECTRON_MCP_ENABLED=true
# Chrome DevTools debugging port for Electron connection (default: 9222)
# ELECTRON_DEBUG_PORT=9222
# =============================================================================
# GRAPHITI MEMORY INTEGRATION (REQUIRED)
# =============================================================================
# Graphiti-based persistent memory layer for cross-session context
# retention. Uses LadybugDB as the embedded graph database.
#
# REQUIREMENTS:
# - Python 3.12 or higher
# - Install: pip install real_ladybug graphiti-core
#
# Supports multiple LLM and embedder providers:
# - OpenAI (default)
# - Anthropic (LLM only, use with Voyage for embeddings)
# - Azure OpenAI
# - Ollama (local, fully offline)
# - Google AI (Gemini)
# Graphiti is enabled by default. Set to false to disable memory features.
GRAPHITI_ENABLED=true
# =============================================================================
# GRAPHITI: Database Settings
# =============================================================================
# LadybugDB stores data in a local directory (no Docker required).
# Database name (default: auto_claude_memory)
# GRAPHITI_DATABASE=auto_claude_memory
# Database storage path (default: ~/.auto-claude/memories)
# GRAPHITI_DB_PATH=~/.auto-claude/memories
# =============================================================================
# GRAPHITI: Provider Selection
# =============================================================================
# Choose which providers to use for LLM and embeddings.
# Default is "openai" for both.
# LLM provider: openai | anthropic | azure_openai | ollama | google | openrouter
# GRAPHITI_LLM_PROVIDER=openai
# Embedder provider: openai | voyage | azure_openai | ollama | google | openrouter
# GRAPHITI_EMBEDDER_PROVIDER=openai
# =============================================================================
# GRAPHITI: OpenAI Provider (Default)
# =============================================================================
# Use OpenAI for both LLM and embeddings. This is the simplest setup.
# Required: OPENAI_API_KEY
# OpenAI API Key
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OpenAI Model for LLM (default: gpt-4o-mini)
# OPENAI_MODEL=gpt-4o-mini
# OpenAI Model for embeddings (default: text-embedding-3-small)
# Available: text-embedding-3-small (1536 dim), text-embedding-3-large (3072 dim)
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
# =============================================================================
# GRAPHITI: Anthropic Provider (LLM only)
# =============================================================================
# Use Anthropic for LLM. Requires separate embedder (use Voyage or OpenAI).
# Example: GRAPHITI_LLM_PROVIDER=anthropic, GRAPHITI_EMBEDDER_PROVIDER=voyage
#
# Required: ANTHROPIC_API_KEY
# Anthropic API Key
# ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Anthropic Model (default: claude-sonnet-4-5-latest)
# GRAPHITI_ANTHROPIC_MODEL=claude-sonnet-4-5-latest
# =============================================================================
# GRAPHITI: Voyage AI Provider (Embeddings only)
# =============================================================================
# Use Voyage AI for embeddings. Commonly paired with Anthropic LLM.
# Get API key from: https://www.voyageai.com/
#
# Required: VOYAGE_API_KEY
# Voyage AI API Key
# VOYAGE_API_KEY=pa-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Voyage Embedding Model (default: voyage-3)
# Available: voyage-3 (1024 dim), voyage-3-lite (512 dim)
# VOYAGE_EMBEDDING_MODEL=voyage-3
# =============================================================================
# GRAPHITI: Google AI Provider
# =============================================================================
# Use Google AI (Gemini) for both LLM and embeddings.
# Get API key from: https://aistudio.google.com/apikey
#
# Required: GOOGLE_API_KEY
# Google AI API Key
# GOOGLE_API_KEY=AIzaSyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Google LLM Model (default: gemini-2.0-flash)
# GOOGLE_LLM_MODEL=gemini-2.0-flash
# Google Embedding Model (default: text-embedding-004)
# GOOGLE_EMBEDDING_MODEL=text-embedding-004
# =============================================================================
# GRAPHITI: OpenRouter Provider (Multi-provider aggregator)
# =============================================================================
# Use OpenRouter to access multiple LLM providers through a single API.
# OpenRouter provides access to Anthropic, OpenAI, Google, and many other models.
# Get API key from: https://openrouter.ai/keys
#
# Required: OPENROUTER_API_KEY
# OpenRouter API Key
# OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# OpenRouter LLM Model (default: anthropic/claude-3.5-sonnet)
# Popular choices: anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
# =============================================================================
# GRAPHITI: Azure OpenAI Provider
# =============================================================================
# Use Azure OpenAI for both LLM and embeddings.
# Requires Azure OpenAI deployment with appropriate models.
#
# Required: AZURE_OPENAI_API_KEY, AZURE_OPENAI_BASE_URL
# Azure OpenAI API Key
# AZURE_OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Azure OpenAI Base URL (your Azure endpoint)
# AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/openai/deployments/your-deployment
# Azure OpenAI Deployment Names
# AZURE_OPENAI_LLM_DEPLOYMENT=gpt-4
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
# =============================================================================
# GRAPHITI: Ollama Provider (Local/Offline)
# =============================================================================
# Use Ollama for fully offline operation. No API keys required.
# Requires Ollama running locally with appropriate models pulled.
#
# Prerequisites:
# 1. Install Ollama: https://ollama.ai/
# 2. Pull models: ollama pull deepseek-r1:7b && ollama pull nomic-embed-text
# 3. Start Ollama server (usually auto-starts)
#
# Required: OLLAMA_LLM_MODEL, OLLAMA_EMBEDDING_MODEL, OLLAMA_EMBEDDING_DIM
# Ollama Server URL (default: http://localhost:11434)
# OLLAMA_BASE_URL=http://localhost:11434
# Ollama LLM Model
# Popular choices: deepseek-r1:7b, llama3.2:3b, mistral:7b, phi3:medium
# OLLAMA_LLM_MODEL=deepseek-r1:7b
# Ollama Embedding Model
# Popular choices: nomic-embed-text (768 dim), mxbai-embed-large (1024 dim)
# OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Ollama Embedding Dimension (REQUIRED for Ollama embeddings)
# Must match your embedding model's output dimension
# Common values: nomic-embed-text=768, mxbai-embed-large=1024, all-minilm=384
# OLLAMA_EMBEDDING_DIM=768
# =============================================================================
# GRAPHITI: Example Configurations
# =============================================================================
#
# --- Example 1: OpenAI (simplest) ---
# GRAPHITI_ENABLED=true
# GRAPHITI_LLM_PROVIDER=openai
# GRAPHITI_EMBEDDER_PROVIDER=openai
# OPENAI_API_KEY=sk-xxxxxxxx
#
# --- Example 2: Anthropic + Voyage (high quality) ---
# GRAPHITI_ENABLED=true
# GRAPHITI_LLM_PROVIDER=anthropic
# GRAPHITI_EMBEDDER_PROVIDER=voyage
# ANTHROPIC_API_KEY=sk-ant-xxxxxxxx
# VOYAGE_API_KEY=pa-xxxxxxxx
#
# --- Example 3: Ollama (fully offline) ---
# GRAPHITI_ENABLED=true
# GRAPHITI_LLM_PROVIDER=ollama
# GRAPHITI_EMBEDDER_PROVIDER=ollama
# OLLAMA_LLM_MODEL=deepseek-r1:7b
# OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# OLLAMA_EMBEDDING_DIM=768
#
# --- Example 4: Azure OpenAI (enterprise) ---
# GRAPHITI_ENABLED=true
# GRAPHITI_LLM_PROVIDER=azure_openai
# GRAPHITI_EMBEDDER_PROVIDER=azure_openai
# AZURE_OPENAI_API_KEY=xxxxxxxx
# AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/...
# AZURE_OPENAI_LLM_DEPLOYMENT=gpt-4
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
#
# --- Example 5: Google AI (Gemini) ---
# GRAPHITI_ENABLED=true
# GRAPHITI_LLM_PROVIDER=google
# GRAPHITI_EMBEDDER_PROVIDER=google
# GOOGLE_API_KEY=AIzaSyxxxxxxxx
#
# --- Example 6: OpenRouter (multi-provider aggregator) ---
# GRAPHITI_ENABLED=true
# GRAPHITI_LLM_PROVIDER=openrouter
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
+66
View File
@@ -0,0 +1,66 @@
# Environment files
.env
.env.local
.env.*.local
# Virtual environment
.venv/
.venv*/
venv/
env/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Puppeteer / Browser automation
puppeteer_logs/
puppeteer-*.log
*.screenshot.png
screenshots/
.puppeteerrc.*
chrome-profile/
chromium-profile/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git worktrees (used by parallel mode)
.worktrees/
# Claude Code settings (project-specific)
.claude_settings.json
.auto-build-security.json
# Tests (development only)
tests/
# Auto Claude data directory
.auto-claude/
+120
View File
@@ -0,0 +1,120 @@
# Auto Claude Backend
Autonomous coding framework powered by Claude AI. Builds software features through coordinated multi-agent sessions.
## Getting Started
### 1. Install
```bash
cd apps/backend
python -m pip install -r requirements.txt
```
### 2. Configure
```bash
cp .env.example .env
```
Set your Claude API token in `.env`:
```
CLAUDE_CODE_OAUTH_TOKEN=your-token-here
```
Get your token by running: `claude setup-token`
### 3. Run
```bash
# List available specs
python run.py --list
# Run a spec
python run.py --spec 001
```
## Requirements
- Python 3.10+
- Claude API token
## Commands
| Command | Description |
|---------|-------------|
| `--list` | List all specs |
| `--spec 001` | Run spec 001 |
| `--spec 001 --isolated` | Run in isolated workspace |
| `--spec 001 --direct` | Run directly in repo |
| `--spec 001 --merge` | Merge completed build |
| `--spec 001 --review` | Review build changes |
| `--spec 001 --discard` | Discard build |
| `--spec 001 --qa` | Run QA validation |
| `--list-worktrees` | List all worktrees |
| `--help` | Show all options |
## Configuration
Optional `.env` settings:
| Variable | Description |
|----------|-------------|
| `AUTO_BUILD_MODEL` | Override Claude model |
| `DEBUG=true` | Enable debug logging |
| `LINEAR_API_KEY` | Enable Linear integration |
| `GRAPHITI_ENABLED=true` | Enable memory system |
## Troubleshooting
**"tree-sitter not available"** - Safe to ignore, uses regex fallback.
**Missing module errors** - Run `python -m pip install -r requirements.txt`
**Debug mode** - Set `DEBUG=true DEBUG_LEVEL=2` before running.
---
## For Developers
### Project Structure
```
backend/
├── agents/ # AI agent execution
├── analysis/ # Code analysis
├── cli/ # Command-line interface
├── core/ # Core utilities
├── integrations/ # External services (Linear, Graphiti)
├── merge/ # Git merge handling
├── project/ # Project detection
├── prompts/ # Prompt templates
├── qa/ # QA validation
├── spec/ # Spec management
└── ui/ # Terminal UI
```
### Design Principles
- **SOLID** - Single responsibility, clean interfaces
- **DRY** - Shared utilities in `core/`
- **KISS** - Simple flat imports via facade modules
### Import Convention
```python
# Use facade modules for clean imports
from debug import debug, debug_error
from progress import count_subtasks
from workspace import setup_workspace
```
### Adding Features
1. Create module in appropriate folder
2. Export API in `__init__.py`
3. Add facade module at root if commonly imported
## License
AGPL-3.0
+23
View File
@@ -0,0 +1,23 @@
"""
Auto Claude Backend - Autonomous Coding Framework
==================================================
Multi-agent autonomous coding framework that builds software through
coordinated AI agent sessions.
This package provides:
- Autonomous agent execution for building features from specs
- Workspace isolation via git worktrees
- QA validation loops
- Memory management (Graphiti + file-based)
- Linear integration for project management
Quick Start:
python run.py --spec 001 # Run a spec
python run.py --list # List all specs
See README.md for full documentation.
"""
__version__ = "2.7.2"
__author__ = "Auto Claude Team"
+3
View File
@@ -0,0 +1,3 @@
"""Backward compatibility shim - import from core.agent instead."""
from core.agent import * # noqa: F403
+152
View File
@@ -0,0 +1,152 @@
# Agents Module
Modular agent system for autonomous coding. This module refactors the original monolithic `agent.py` (1,446 lines) into focused, maintainable modules.
## Architecture
The agent system is now organized by concern:
```
auto-claude/agents/
├── __init__.py # Public API exports
├── base.py # Shared constants and imports
├── utils.py # Git operations and plan management
├── memory.py # Memory management (Graphiti + file-based)
├── session.py # Agent session execution
├── planner.py # Follow-up planner logic
└── coder.py # Main autonomous agent loop
```
## Modules
### `base.py` (352 bytes)
- Shared constants (`AUTO_CONTINUE_DELAY_SECONDS`, `HUMAN_INTERVENTION_FILE`)
- Common imports and logging setup
### `utils.py` (3.6 KB)
- Git operations: `get_latest_commit()`, `get_commit_count()`
- Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()`
- Workspace sync: `sync_plan_to_source()`
### `memory.py` (13 KB)
- Dual-layer memory system (Graphiti primary, file-based fallback)
- `debug_memory_system_status()` - Memory system diagnostics
- `get_graphiti_context()` - Retrieve relevant context for subtasks
- `save_session_memory()` - Save session insights to memory
- `save_session_to_graphiti()` - Backwards compatibility wrapper
### `session.py` (17 KB)
- `run_agent_session()` - Execute a single agent session
- `post_session_processing()` - Process results and update memory
- Session logging and tool tracking
- Recovery manager integration
### `planner.py` (5.4 KB)
- `run_followup_planner()` - Add new subtasks to completed specs
- Follow-up planning workflow
- Plan validation and status updates
### `coder.py` (16 KB)
- `run_autonomous_agent()` - Main autonomous agent loop
- Planning and coding phase management
- Linear integration
- Recovery and stuck subtask handling
## Public API
The `agents` module exports a clean public API:
```python
from agents import (
# Main functions
run_autonomous_agent,
run_followup_planner,
# Memory functions
save_session_memory,
get_graphiti_context,
# Session management
run_agent_session,
post_session_processing,
# Utilities
get_latest_commit,
load_implementation_plan,
sync_plan_to_source,
)
```
## Backwards Compatibility
The original `agent.py` is now a facade that re-exports everything from the `agents` module:
```python
# Old code still works
from agent import run_autonomous_agent, save_session_memory
# New code can use modular imports
from agents.coder import run_autonomous_agent
from agents.memory import save_session_memory
```
All existing imports continue to work without changes.
## Benefits
1. **Separation of Concerns**: Each module has a clear, focused responsibility
2. **Maintainability**: Easier to understand and modify individual components
3. **Testability**: Modules can be tested in isolation
4. **Backwards Compatible**: No breaking changes to existing code
5. **Scalability**: Easy to add new agent types or features
## Module Dependencies
```
coder.py
├── session.py (run_agent_session, post_session_processing)
├── memory.py (get_graphiti_context, debug_memory_system_status)
└── utils.py (git operations, plan management)
session.py
├── memory.py (save_session_memory)
└── utils.py (git operations, plan management)
planner.py
└── session.py (run_agent_session)
memory.py
└── base.py (constants, logging)
```
## Testing
Run the verification script to test the refactoring:
```bash
python3 auto-claude/agents/test_refactoring.py
```
This verifies:
- Module structure is correct
- All imports work
- Public API is accessible
- Backwards compatibility is maintained
## Migration Guide
No migration needed! The refactoring maintains 100% backwards compatibility.
### For new code:
```python
# Use focused imports for clarity
from agents.coder import run_autonomous_agent
from agents.memory import save_session_memory, get_graphiti_context
from agents.session import run_agent_session
```
### For existing code:
```python
# Old imports continue to work
from agent import run_autonomous_agent, save_session_memory
```
+92
View File
@@ -0,0 +1,92 @@
"""
Agents Module
=============
Modular agent system for autonomous coding.
This module provides:
- run_autonomous_agent: Main coder agent loop
- run_followup_planner: Follow-up planner for completed specs
- Memory management (Graphiti + file-based fallback)
- Session management and post-processing
- Utility functions for git and plan management
Uses lazy imports to avoid circular dependencies.
"""
__all__ = [
# Main API
"run_autonomous_agent",
"run_followup_planner",
# Memory
"debug_memory_system_status",
"get_graphiti_context",
"save_session_memory",
"save_session_to_graphiti",
# Session
"run_agent_session",
"post_session_processing",
# Utils
"get_latest_commit",
"get_commit_count",
"load_implementation_plan",
"find_subtask_in_plan",
"find_phase_for_subtask",
"sync_plan_to_source",
# Constants
"AUTO_CONTINUE_DELAY_SECONDS",
"HUMAN_INTERVENTION_FILE",
]
def __getattr__(name):
"""Lazy imports to avoid circular dependencies."""
if name in ("AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE"):
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
return locals()[name]
elif name == "run_autonomous_agent":
from .coder import run_autonomous_agent
return run_autonomous_agent
elif name in (
"debug_memory_system_status",
"get_graphiti_context",
"save_session_memory",
"save_session_to_graphiti",
):
from .memory_manager import (
debug_memory_system_status,
get_graphiti_context,
save_session_memory,
save_session_to_graphiti,
)
return locals()[name]
elif name == "run_followup_planner":
from .planner import run_followup_planner
return run_followup_planner
elif name in ("post_session_processing", "run_agent_session"):
from .session import post_session_processing, run_agent_session
return locals()[name]
elif name in (
"find_phase_for_subtask",
"find_subtask_in_plan",
"get_commit_count",
"get_latest_commit",
"load_implementation_plan",
"sync_plan_to_source",
):
from .utils import (
find_phase_for_subtask,
find_subtask_in_plan,
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_plan_to_source,
)
return locals()[name]
raise AttributeError(f"module 'agents' has no attribute '{name}'")
+15
View File
@@ -0,0 +1,15 @@
"""
Base Module for Agent System
=============================
Shared imports, types, and constants used across agent modules.
"""
import logging
# Configure logging
logger = logging.getLogger(__name__)
# Configuration constants
AUTO_CONTINUE_DELAY_SECONDS = 3
HUMAN_INTERVENTION_FILE = "PAUSE"
+499
View File
@@ -0,0 +1,499 @@
"""
Coder Agent Module
==================
Main autonomous agent loop that runs the coder agent to implement subtasks.
"""
import asyncio
import logging
from pathlib import Path
from core.client import create_client
from linear_updater import (
LinearTaskState,
is_linear_enabled,
linear_build_complete,
linear_task_started,
linear_task_stuck,
)
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
from progress import (
count_subtasks,
count_subtasks_detailed,
get_current_phase,
get_next_subtask,
is_build_complete,
print_build_complete_banner,
print_progress_summary,
print_session_header,
)
from prompt_generator import (
format_context_for_prompt,
generate_planner_prompt,
generate_subtask_prompt,
load_subtask_context,
)
from prompts import is_first_run
from recovery import RecoveryManager
from task_logger import (
LogPhase,
get_task_logger,
)
from ui import (
BuildState,
Icons,
StatusManager,
bold,
box,
highlight,
icon,
muted,
print_key_value,
print_status,
)
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
from .memory_manager import debug_memory_system_status, get_graphiti_context
from .session import post_session_processing, run_agent_session
from .utils import (
find_phase_for_subtask,
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_plan_to_source,
)
logger = logging.getLogger(__name__)
async def run_autonomous_agent(
project_dir: Path,
spec_dir: Path,
model: str,
max_iterations: int | None = None,
verbose: bool = False,
source_spec_dir: Path | None = None,
) -> None:
"""
Run the autonomous agent loop with automatic memory management.
The agent can use subagents (via Task tool) for parallel execution if needed.
This is decided by the agent itself based on the task complexity.
Args:
project_dir: Root directory for the project
spec_dir: Directory containing the spec (auto-claude/specs/001-name/)
model: Claude model to use
max_iterations: Maximum number of iterations (None for unlimited)
verbose: Whether to show detailed output
source_spec_dir: Original spec directory in main project (for syncing from worktree)
"""
# Initialize recovery manager (handles memory persistence)
recovery_manager = RecoveryManager(spec_dir, project_dir)
# Initialize status manager for ccstatusline
status_manager = StatusManager(project_dir)
status_manager.set_active(spec_dir.name, BuildState.BUILDING)
# Initialize task logger for persistent logging
task_logger = get_task_logger(spec_dir)
# Debug: Print memory system status at startup
debug_memory_system_status()
# Update initial subtask counts
subtasks = count_subtasks_detailed(spec_dir)
status_manager.update_subtasks(
completed=subtasks["completed"],
total=subtasks["total"],
in_progress=subtasks["in_progress"],
)
# Check Linear integration status
linear_task = None
if is_linear_enabled():
linear_task = LinearTaskState.load(spec_dir)
if linear_task and linear_task.task_id:
print_status("Linear integration: ENABLED", "success")
print_key_value("Task", linear_task.task_id)
print_key_value("Status", linear_task.status)
print()
else:
print_status("Linear enabled but no task created for this spec", "warning")
print()
# Check if this is a fresh start or continuation
first_run = is_first_run(spec_dir)
# Track which phase we're in for logging
current_log_phase = LogPhase.CODING
is_planning_phase = False
if first_run:
print_status(
"Fresh start - will use Planner Agent to create implementation plan", "info"
)
content = [
bold(f"{icon(Icons.GEAR)} PLANNER SESSION"),
"",
f"Spec: {highlight(spec_dir.name)}",
muted("The agent will analyze your spec and create a subtask-based plan."),
]
print()
print(box(content, width=70, style="heavy"))
print()
# Update status for planning phase
status_manager.update(state=BuildState.PLANNING)
emit_phase(ExecutionPhase.PLANNING, "Creating implementation plan")
is_planning_phase = True
current_log_phase = LogPhase.PLANNING
# Start planning phase in task logger
if task_logger:
task_logger.start_phase(
LogPhase.PLANNING, "Starting implementation planning..."
)
# Update Linear to "In Progress" when build starts
if linear_task and linear_task.task_id:
print_status("Updating Linear task to In Progress...", "progress")
await linear_task_started(spec_dir)
else:
print(f"Continuing build: {highlight(spec_dir.name)}")
print_progress_summary(spec_dir)
# Check if already complete
if is_build_complete(spec_dir):
print_build_complete_banner(spec_dir)
status_manager.update(state=BuildState.COMPLETE)
return
# Start/continue coding phase in task logger
if task_logger:
task_logger.start_phase(LogPhase.CODING, "Continuing implementation...")
# Emit phase event when continuing build
emit_phase(ExecutionPhase.CODING, "Continuing implementation")
# Show human intervention hint
content = [
bold("INTERACTIVE CONTROLS"),
"",
f"Press {highlight('Ctrl+C')} once {icon(Icons.ARROW_RIGHT)} Pause and optionally add instructions",
f"Press {highlight('Ctrl+C')} twice {icon(Icons.ARROW_RIGHT)} Exit immediately",
]
print(box(content, width=70, style="light"))
print()
# Main loop
iteration = 0
while True:
iteration += 1
# Check for human intervention (PAUSE file)
pause_file = spec_dir / HUMAN_INTERVENTION_FILE
if pause_file.exists():
print("\n" + "=" * 70)
print(" PAUSED BY HUMAN")
print("=" * 70)
pause_content = pause_file.read_text().strip()
if pause_content:
print(f"\nMessage: {pause_content}")
print("\nTo resume, delete the PAUSE file:")
print(f" rm {pause_file}")
print("\nThen run again:")
print(f" python auto-claude/run.py --spec {spec_dir.name}")
return
# Check max iterations
if max_iterations and iteration > max_iterations:
print(f"\nReached max iterations ({max_iterations})")
print("To continue, run the script again without --max-iterations")
break
# Get the next subtask to work on
next_subtask = get_next_subtask(spec_dir)
subtask_id = next_subtask.get("id") if next_subtask else None
phase_name = next_subtask.get("phase_name") if next_subtask else None
# Update status for this session
status_manager.update_session(iteration)
if phase_name:
current_phase = get_current_phase(spec_dir)
if current_phase:
status_manager.update_phase(
current_phase.get("name", ""),
current_phase.get("phase", 0),
current_phase.get("total", 0),
)
status_manager.update_subtasks(in_progress=1)
# Print session header
print_session_header(
session_num=iteration,
is_planner=first_run,
subtask_id=subtask_id,
subtask_desc=next_subtask.get("description") if next_subtask else None,
phase_name=phase_name,
attempt=recovery_manager.get_attempt_count(subtask_id) + 1
if subtask_id
else 1,
)
# Capture state before session for post-processing
commit_before = get_latest_commit(project_dir)
commit_count_before = get_commit_count(project_dir)
# Get the phase-specific model and thinking level (respects task_metadata.json configuration)
# first_run means we're in planning phase, otherwise coding phase
current_phase = "planning" if first_run else "coding"
phase_model = get_phase_model(spec_dir, current_phase, model)
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
# Create client (fresh context) with phase-specific model and thinking
client = create_client(
project_dir,
spec_dir,
phase_model,
max_thinking_tokens=phase_thinking_budget,
)
# Generate appropriate prompt
if first_run:
prompt = generate_planner_prompt(spec_dir, project_dir)
first_run = False
current_log_phase = LogPhase.PLANNING
# Set session info in logger
if task_logger:
task_logger.set_session(iteration)
else:
# Switch to coding phase after planning
if is_planning_phase:
is_planning_phase = False
current_log_phase = LogPhase.CODING
emit_phase(ExecutionPhase.CODING, "Starting implementation")
if task_logger:
task_logger.end_phase(
LogPhase.PLANNING,
success=True,
message="Implementation plan created",
)
task_logger.start_phase(
LogPhase.CODING, "Starting implementation..."
)
if not next_subtask:
print("No pending subtasks found - build may be complete!")
break
# Get attempt count for recovery context
attempt_count = recovery_manager.get_attempt_count(subtask_id)
recovery_hints = (
recovery_manager.get_recovery_hints(subtask_id)
if attempt_count > 0
else None
)
# Find the phase for this subtask
plan = load_implementation_plan(spec_dir)
phase = find_phase_for_subtask(plan, subtask_id) if plan else {}
# Generate focused, minimal prompt for this subtask
prompt = generate_subtask_prompt(
spec_dir=spec_dir,
project_dir=project_dir,
subtask=next_subtask,
phase=phase or {},
attempt_count=attempt_count,
recovery_hints=recovery_hints,
)
# Load and append relevant file context
context = load_subtask_context(spec_dir, project_dir, next_subtask)
if context.get("patterns") or context.get("files_to_modify"):
prompt += "\n\n" + format_context_for_prompt(context)
# Retrieve and append Graphiti memory context (if enabled)
graphiti_context = await get_graphiti_context(
spec_dir, project_dir, next_subtask
)
if graphiti_context:
prompt += "\n\n" + graphiti_context
print_status("Graphiti memory context loaded", "success")
# Show what we're working on
print(f"Working on: {highlight(subtask_id)}")
print(f"Description: {next_subtask.get('description', 'No description')}")
if attempt_count > 0:
print_status(f"Previous attempts: {attempt_count}", "warning")
print()
# Set subtask info in logger
if task_logger and subtask_id:
task_logger.set_subtask(subtask_id)
task_logger.set_session(iteration)
# Run session with async context manager
async with client:
status, response = await run_agent_session(
client, prompt, spec_dir, verbose, phase=current_log_phase
)
# === POST-SESSION PROCESSING (100% reliable) ===
if subtask_id and not first_run:
linear_is_enabled = (
linear_task is not None and linear_task.task_id is not None
)
success = await post_session_processing(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
session_num=iteration,
commit_before=commit_before,
commit_count_before=commit_count_before,
recovery_manager=recovery_manager,
linear_enabled=linear_is_enabled,
status_manager=status_manager,
source_spec_dir=source_spec_dir,
)
# Check for stuck subtasks
attempt_count = recovery_manager.get_attempt_count(subtask_id)
if not success and attempt_count >= 3:
recovery_manager.mark_subtask_stuck(
subtask_id, f"Failed after {attempt_count} attempts"
)
print()
print_status(
f"Subtask {subtask_id} marked as STUCK after {attempt_count} attempts",
"error",
)
print(muted("Consider: manual intervention or skipping this subtask"))
# Record stuck subtask in Linear (if enabled)
if linear_is_enabled:
await linear_task_stuck(
spec_dir=spec_dir,
subtask_id=subtask_id,
attempt_count=attempt_count,
)
print_status("Linear notified of stuck subtask", "info")
elif is_planning_phase and source_spec_dir:
# After planning phase, sync the newly created implementation plan back to source
if sync_plan_to_source(spec_dir, source_spec_dir):
print_status("Implementation plan synced to main project", "success")
# Handle session status
if status == "complete":
# Don't emit COMPLETE here - subtasks are done but QA hasn't run yet
# QA loop will emit COMPLETE after actual approval
print_build_complete_banner(spec_dir)
status_manager.update(state=BuildState.COMPLETE)
if task_logger:
task_logger.end_phase(
LogPhase.CODING,
success=True,
message="All subtasks completed successfully",
)
if linear_task and linear_task.task_id:
await linear_build_complete(spec_dir)
print_status("Linear notified: build complete, ready for QA", "success")
break
elif status == "continue":
print(
muted(
f"\nAgent will auto-continue in {AUTO_CONTINUE_DELAY_SECONDS}s..."
)
)
print_progress_summary(spec_dir)
# Update state back to building
status_manager.update(state=BuildState.BUILDING)
# Show next subtask info
next_subtask = get_next_subtask(spec_dir)
if next_subtask:
subtask_id = next_subtask.get("id")
print(
f"\nNext: {highlight(subtask_id)} - {next_subtask.get('description')}"
)
attempt_count = recovery_manager.get_attempt_count(subtask_id)
if attempt_count > 0:
print_status(
f"WARNING: {attempt_count} previous attempt(s)", "warning"
)
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
elif status == "error":
emit_phase(ExecutionPhase.FAILED, "Session encountered an error")
print_status("Session encountered an error", "error")
print(muted("Will retry with a fresh session..."))
status_manager.update(state=BuildState.ERROR)
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
# Small delay between sessions
if max_iterations is None or iteration < max_iterations:
print("\nPreparing next session...\n")
await asyncio.sleep(1)
# Final summary
content = [
bold(f"{icon(Icons.SESSION)} SESSION SUMMARY"),
"",
f"Project: {project_dir}",
f"Spec: {highlight(spec_dir.name)}",
f"Sessions completed: {iteration}",
]
print()
print(box(content, width=70, style="heavy"))
print_progress_summary(spec_dir)
# Show stuck subtasks if any
stuck_subtasks = recovery_manager.get_stuck_subtasks()
if stuck_subtasks:
print()
print_status("STUCK SUBTASKS (need manual intervention):", "error")
for stuck in stuck_subtasks:
print(f" {icon(Icons.ERROR)} {stuck['subtask_id']}: {stuck['reason']}")
# Instructions
completed, total = count_subtasks(spec_dir)
if completed < total:
content = [
bold(f"{icon(Icons.PLAY)} NEXT STEPS"),
"",
f"{total - completed} subtasks remaining.",
f"Run again: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
]
else:
content = [
bold(f"{icon(Icons.SUCCESS)} NEXT STEPS"),
"",
"All subtasks completed!",
" 1. Review the auto-claude/* branch",
" 2. Run manual tests",
" 3. Merge to main",
]
print()
print(box(content, width=70, style="light"))
print()
# Set final status
if completed == total:
status_manager.update(state=BuildState.COMPLETE)
else:
status_manager.update(state=BuildState.PAUSED)
+416
View File
@@ -0,0 +1,416 @@
"""
Memory Management for Agent System
===================================
Handles session memory storage using dual-layer approach:
- PRIMARY: Graphiti (when enabled) - semantic search, cross-session context
- FALLBACK: File-based memory - zero dependencies, always available
"""
import logging
from pathlib import Path
from debug import (
debug,
debug_detailed,
debug_error,
debug_section,
debug_success,
debug_warning,
is_debug_enabled,
)
from graphiti_config import get_graphiti_status, is_graphiti_enabled
# Import from parent memory package
# Now safe since this module is named memory_manager (not memory)
from memory import save_session_insights as save_file_based_memory
logger = logging.getLogger(__name__)
def debug_memory_system_status() -> None:
"""
Print memory system status for debugging.
Called at startup when DEBUG=true to show memory configuration.
"""
if not is_debug_enabled():
return
debug_section("memory", "Memory System Status")
# Get Graphiti status
graphiti_status = get_graphiti_status()
debug(
"memory",
"Memory system configuration",
primary_system="Graphiti"
if graphiti_status.get("available")
else "File-based (fallback)",
graphiti_enabled=graphiti_status.get("enabled"),
graphiti_available=graphiti_status.get("available"),
)
if graphiti_status.get("enabled"):
debug_detailed(
"memory",
"Graphiti configuration",
host=graphiti_status.get("host"),
port=graphiti_status.get("port"),
database=graphiti_status.get("database"),
llm_provider=graphiti_status.get("llm_provider"),
embedder_provider=graphiti_status.get("embedder_provider"),
)
if not graphiti_status.get("available"):
debug_warning(
"memory",
"Graphiti not available",
reason=graphiti_status.get("reason"),
errors=graphiti_status.get("errors"),
)
debug("memory", "Will use file-based memory as fallback")
else:
debug_success("memory", "Graphiti ready as PRIMARY memory system")
else:
debug(
"memory",
"Graphiti disabled, using file-based memory only",
note="Set GRAPHITI_ENABLED=true to enable Graphiti",
)
async def get_graphiti_context(
spec_dir: Path,
project_dir: Path,
subtask: dict,
) -> str | None:
"""
Retrieve relevant context from Graphiti for the current subtask.
This searches the knowledge graph for context relevant to the subtask's
task description, returning past insights, patterns, and gotchas.
Args:
spec_dir: Spec directory
project_dir: Project root directory
subtask: The current subtask being worked on
Returns:
Formatted context string or None if unavailable
"""
if is_debug_enabled():
debug(
"memory",
"Retrieving Graphiti context for subtask",
subtask_id=subtask.get("id", "unknown"),
subtask_desc=subtask.get("description", "")[:100],
)
if not is_graphiti_enabled():
if is_debug_enabled():
debug("memory", "Graphiti not enabled, skipping context retrieval")
return None
try:
from graphiti_memory import GraphitiMemory
# Create memory manager
memory = GraphitiMemory(spec_dir, project_dir)
if not memory.is_enabled:
if is_debug_enabled():
debug_warning("memory", "GraphitiMemory.is_enabled=False")
return None
# Build search query from subtask description
subtask_desc = subtask.get("description", "")
subtask_id = subtask.get("id", "")
query = f"{subtask_desc} {subtask_id}".strip()
if not query:
await memory.close()
if is_debug_enabled():
debug_warning("memory", "Empty query, skipping context retrieval")
return None
if is_debug_enabled():
debug_detailed(
"memory",
"Searching Graphiti knowledge graph",
query=query[:200],
num_results=5,
)
# Get relevant context
context_items = await memory.get_relevant_context(query, num_results=5)
# Also get recent session history
session_history = await memory.get_session_history(limit=3)
await memory.close()
if is_debug_enabled():
debug(
"memory",
"Graphiti context retrieval complete",
context_items_found=len(context_items) if context_items else 0,
session_history_found=len(session_history) if session_history else 0,
)
if not context_items and not session_history:
if is_debug_enabled():
debug("memory", "No relevant context found in Graphiti")
return None
# Format the context
sections = ["## Graphiti Memory Context\n"]
sections.append("_Retrieved from knowledge graph for this subtask:_\n")
if context_items:
sections.append("### Relevant Knowledge\n")
for item in context_items:
content = item.get("content", "")[:500] # Truncate
item_type = item.get("type", "unknown")
sections.append(f"- **[{item_type}]** {content}\n")
if session_history:
sections.append("### Recent Session Insights\n")
for session in session_history[:2]: # Only show last 2
session_num = session.get("session_number", "?")
recommendations = session.get("recommendations_for_next_session", [])
if recommendations:
sections.append(f"**Session {session_num} recommendations:**")
for rec in recommendations[:3]: # Limit to 3
sections.append(f"- {rec}")
sections.append("")
if is_debug_enabled():
debug_success(
"memory", "Graphiti context formatted", total_sections=len(sections)
)
return "\n".join(sections)
except ImportError:
logger.debug("Graphiti packages not installed")
if is_debug_enabled():
debug_warning("memory", "Graphiti packages not installed")
return None
except Exception as e:
logger.warning(f"Failed to get Graphiti context: {e}")
return None
async def save_session_memory(
spec_dir: Path,
project_dir: Path,
subtask_id: str,
session_num: int,
success: bool,
subtasks_completed: list[str],
discoveries: dict | None = None,
) -> tuple[bool, str]:
"""
Save session insights to memory.
Memory Strategy:
- PRIMARY: Graphiti (when enabled) - provides semantic search, cross-session context
- FALLBACK: File-based (when Graphiti is disabled) - zero dependencies, always works
This is called after each session to persist learnings.
Args:
spec_dir: Spec directory
project_dir: Project root directory
subtask_id: The subtask that was worked on
session_num: Current session number
success: Whether the subtask was completed successfully
subtasks_completed: List of subtask IDs completed this session
discoveries: Optional dict with file discoveries, patterns, gotchas
Returns:
Tuple of (success, storage_type) where storage_type is "graphiti" or "file"
"""
# Debug: Log memory save start
if is_debug_enabled():
debug_section("memory", f"Saving Session {session_num} Memory")
debug(
"memory",
"Memory save initiated",
subtask_id=subtask_id,
session_num=session_num,
success=success,
subtasks_completed=subtasks_completed,
spec_dir=str(spec_dir),
)
# Build insights structure (same format for both storage systems)
insights = {
"subtasks_completed": subtasks_completed,
"discoveries": discoveries
or {
"files_understood": {},
"patterns_found": [],
"gotchas_encountered": [],
},
"what_worked": [f"Implemented subtask: {subtask_id}"] if success else [],
"what_failed": [] if success else [f"Failed to complete subtask: {subtask_id}"],
"recommendations_for_next_session": [],
}
if is_debug_enabled():
debug_detailed("memory", "Insights structure built", insights=insights)
# Check Graphiti status for debugging
graphiti_enabled = is_graphiti_enabled()
if is_debug_enabled():
graphiti_status = get_graphiti_status()
debug(
"memory",
"Graphiti status check",
enabled=graphiti_status.get("enabled"),
available=graphiti_status.get("available"),
host=graphiti_status.get("host"),
port=graphiti_status.get("port"),
database=graphiti_status.get("database"),
llm_provider=graphiti_status.get("llm_provider"),
embedder_provider=graphiti_status.get("embedder_provider"),
reason=graphiti_status.get("reason") or "OK",
)
# PRIMARY: Try Graphiti if enabled
if graphiti_enabled:
if is_debug_enabled():
debug("memory", "Attempting PRIMARY storage: Graphiti")
try:
from graphiti_memory import GraphitiMemory
memory = GraphitiMemory(spec_dir, project_dir)
if is_debug_enabled():
debug_detailed(
"memory",
"GraphitiMemory instance created",
is_enabled=memory.is_enabled,
group_id=getattr(memory, "group_id", "unknown"),
)
if memory.is_enabled:
if is_debug_enabled():
debug("memory", "Saving to Graphiti...")
# Use structured insights if we have rich extracted data
if discoveries and discoveries.get("file_insights"):
# Rich insights from insight_extractor
if is_debug_enabled():
debug(
"memory",
"Using save_structured_insights (rich data available)",
)
result = await memory.save_structured_insights(discoveries)
else:
# Fallback to basic session insights
result = await memory.save_session_insights(session_num, insights)
await memory.close()
if result:
logger.info(
f"Session {session_num} insights saved to Graphiti (primary)"
)
if is_debug_enabled():
debug_success(
"memory",
f"Session {session_num} saved to Graphiti (PRIMARY)",
storage_type="graphiti",
subtasks_saved=len(subtasks_completed),
)
return True, "graphiti"
else:
logger.warning(
"Graphiti save returned False, falling back to file-based"
)
if is_debug_enabled():
debug_warning(
"memory", "Graphiti save returned False, using FALLBACK"
)
else:
logger.warning(
"Graphiti memory not enabled, falling back to file-based"
)
if is_debug_enabled():
debug_warning(
"memory", "GraphitiMemory.is_enabled=False, using FALLBACK"
)
except ImportError as e:
logger.debug("Graphiti packages not installed, falling back to file-based")
if is_debug_enabled():
debug_warning("memory", "Graphiti packages not installed", error=str(e))
except Exception as e:
logger.warning(f"Graphiti save failed: {e}, falling back to file-based")
if is_debug_enabled():
debug_error("memory", "Graphiti save failed", error=str(e))
else:
if is_debug_enabled():
debug("memory", "Graphiti not enabled, skipping to FALLBACK")
# FALLBACK: File-based memory (when Graphiti is disabled or fails)
if is_debug_enabled():
debug("memory", "Attempting FALLBACK storage: File-based")
try:
memory_dir = spec_dir / "memory" / "session_insights"
if is_debug_enabled():
debug_detailed(
"memory",
"File-based memory path",
memory_dir=str(memory_dir),
session_file=f"session_{session_num:03d}.json",
)
save_file_based_memory(spec_dir, session_num, insights)
logger.info(
f"Session {session_num} insights saved to file-based memory (fallback)"
)
if is_debug_enabled():
debug_success(
"memory",
f"Session {session_num} saved to file-based (FALLBACK)",
storage_type="file",
file_path=str(memory_dir / f"session_{session_num:03d}.json"),
subtasks_saved=len(subtasks_completed),
)
return True, "file"
except Exception as e:
logger.error(f"File-based memory save also failed: {e}")
if is_debug_enabled():
debug_error("memory", "File-based memory save FAILED", error=str(e))
return False, "none"
# Keep the old function name as an alias for backwards compatibility
async def save_session_to_graphiti(
spec_dir: Path,
project_dir: Path,
subtask_id: str,
session_num: int,
success: bool,
subtasks_completed: list[str],
discoveries: dict | None = None,
) -> bool:
"""Backwards compatibility wrapper for save_session_memory."""
result, _ = await save_session_memory(
spec_dir,
project_dir,
subtask_id,
session_num,
success,
subtasks_completed,
discoveries,
)
return result
+181
View File
@@ -0,0 +1,181 @@
"""
Planner Agent Module
====================
Handles follow-up planner sessions for adding new subtasks to completed specs.
"""
import logging
from pathlib import Path
from core.client import create_client
from phase_config import get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
from task_logger import (
LogPhase,
get_task_logger,
)
from ui import (
BuildState,
Icons,
StatusManager,
bold,
box,
highlight,
icon,
muted,
print_status,
)
from .session import run_agent_session
logger = logging.getLogger(__name__)
async def run_followup_planner(
project_dir: Path,
spec_dir: Path,
model: str,
verbose: bool = False,
) -> bool:
"""
Run the follow-up planner to add new subtasks to a completed spec.
This is a simplified version of run_autonomous_agent that:
1. Creates a client
2. Loads the followup planner prompt
3. Runs a single planning session
4. Returns after the plan is updated (doesn't enter coding loop)
The planner agent will:
- Read FOLLOWUP_REQUEST.md for the new task
- Read the existing implementation_plan.json
- Add new phase(s) with pending subtasks
- Update the plan status back to in_progress
Args:
project_dir: Root directory for the project
spec_dir: Directory containing the completed spec
model: Claude model to use
verbose: Whether to show detailed output
Returns:
bool: True if planning completed successfully
"""
from implementation_plan import ImplementationPlan
from prompts import get_followup_planner_prompt
# Initialize status manager for ccstatusline
status_manager = StatusManager(project_dir)
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
# Initialize task logger for persistent logging
task_logger = get_task_logger(spec_dir)
# Show header
content = [
bold(f"{icon(Icons.GEAR)} FOLLOW-UP PLANNER SESSION"),
"",
f"Spec: {highlight(spec_dir.name)}",
muted("Adding follow-up work to completed spec."),
"",
muted("The agent will read your FOLLOWUP_REQUEST.md and add new subtasks."),
]
print()
print(box(content, width=70, style="heavy"))
print()
# Start planning phase in task logger
if task_logger:
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
task_logger.set_session(1)
# Create client (fresh context) with planning phase thinking budget
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
client = create_client(
project_dir,
spec_dir,
model,
max_thinking_tokens=planning_thinking_budget,
)
# Generate follow-up planner prompt
prompt = get_followup_planner_prompt(spec_dir)
print_status("Running follow-up planner...", "progress")
print()
try:
# Run single planning session
async with client:
status, response = await run_agent_session(
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
)
# End planning phase in task logger
if task_logger:
task_logger.end_phase(
LogPhase.PLANNING,
success=(status != "error"),
message="Follow-up planning session completed",
)
if status == "error":
print()
print_status("Follow-up planning failed", "error")
status_manager.update(state=BuildState.ERROR)
return False
# Verify the plan was updated (should have pending subtasks now)
plan_file = spec_dir / "implementation_plan.json"
if plan_file.exists():
plan = ImplementationPlan.load(plan_file)
# Check if there are any pending subtasks
all_subtasks = [c for p in plan.phases for c in p.subtasks]
pending_subtasks = [c for c in all_subtasks if c.status.value == "pending"]
if pending_subtasks:
# Reset the plan status to in_progress (in case planner didn't)
plan.reset_for_followup()
plan.save(plan_file)
print()
content = [
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
"",
f"New pending subtasks: {highlight(str(len(pending_subtasks)))}",
f"Total subtasks: {len(all_subtasks)}",
"",
muted("Next steps:"),
f" Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
]
print(box(content, width=70, style="heavy"))
print()
status_manager.update(state=BuildState.PAUSED)
return True
else:
print()
print_status(
"Warning: No pending subtasks found after planning", "warning"
)
print(muted("The planner may not have added new subtasks."))
print(muted("Check implementation_plan.json manually."))
status_manager.update(state=BuildState.PAUSED)
return False
else:
print()
print_status(
"Error: implementation_plan.json not found after planning", "error"
)
status_manager.update(state=BuildState.ERROR)
return False
except Exception as e:
print()
print_status(f"Follow-up planning error: {e}", "error")
if task_logger:
task_logger.log_error(f"Follow-up planning error: {e}", LogPhase.PLANNING)
status_manager.update(state=BuildState.ERROR)
return False
+550
View File
@@ -0,0 +1,550 @@
"""
Agent Session Management
========================
Handles running agent sessions and post-session processing including
memory updates, recovery tracking, and Linear integration.
"""
import logging
from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from insight_extractor import extract_session_insights
from linear_updater import (
linear_subtask_completed,
linear_subtask_failed,
)
from progress import (
count_subtasks_detailed,
is_build_complete,
)
from recovery import RecoveryManager
from task_logger import (
LogEntryType,
LogPhase,
get_task_logger,
)
from ui import (
StatusManager,
muted,
print_key_value,
print_status,
)
from .memory_manager import save_session_memory
from .utils import (
find_subtask_in_plan,
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_plan_to_source,
)
logger = logging.getLogger(__name__)
async def post_session_processing(
spec_dir: Path,
project_dir: Path,
subtask_id: str,
session_num: int,
commit_before: str | None,
commit_count_before: int,
recovery_manager: RecoveryManager,
linear_enabled: bool = False,
status_manager: StatusManager | None = None,
source_spec_dir: Path | None = None,
) -> bool:
"""
Process session results and update memory automatically.
This runs in Python (100% reliable) instead of relying on agent compliance.
Args:
spec_dir: Spec directory containing memory/
project_dir: Project root for git operations
subtask_id: The subtask that was being worked on
session_num: Current session number
commit_before: Git commit hash before session
commit_count_before: Number of commits before session
recovery_manager: Recovery manager instance
linear_enabled: Whether Linear integration is enabled
status_manager: Optional status manager for ccstatusline
source_spec_dir: Original spec directory (for syncing back from worktree)
Returns:
True if subtask was completed successfully
"""
print()
print(muted("--- Post-Session Processing ---"))
# Sync implementation plan back to source (for worktree mode)
if sync_plan_to_source(spec_dir, source_spec_dir):
print_status("Implementation plan synced to main project", "success")
# Check if implementation plan was updated
plan = load_implementation_plan(spec_dir)
if not plan:
print(" Warning: Could not load implementation plan")
return False
subtask = find_subtask_in_plan(plan, subtask_id)
if not subtask:
print(f" Warning: Subtask {subtask_id} not found in plan")
return False
subtask_status = subtask.get("status", "pending")
# Check for new commits
commit_after = get_latest_commit(project_dir)
commit_count_after = get_commit_count(project_dir)
new_commits = commit_count_after - commit_count_before
print_key_value("Subtask status", subtask_status)
print_key_value("New commits", str(new_commits))
if subtask_status == "completed":
# Success! Record the attempt and good commit
print_status(f"Subtask {subtask_id} completed successfully", "success")
# Update status file
if status_manager:
subtasks = count_subtasks_detailed(spec_dir)
status_manager.update_subtasks(
completed=subtasks["completed"],
total=subtasks["total"],
in_progress=0,
)
# Record successful attempt
recovery_manager.record_attempt(
subtask_id=subtask_id,
session=session_num,
success=True,
approach=f"Implemented: {subtask.get('description', 'subtask')[:100]}",
)
# Record good commit for rollback safety
if commit_after and commit_after != commit_before:
recovery_manager.record_good_commit(commit_after, subtask_id)
print_status(f"Recorded good commit: {commit_after[:8]}", "success")
# Record Linear session result (if enabled)
if linear_enabled:
# Get progress counts for the comment
subtasks_detail = count_subtasks_detailed(spec_dir)
await linear_subtask_completed(
spec_dir=spec_dir,
subtask_id=subtask_id,
completed_count=subtasks_detail["completed"],
total_count=subtasks_detail["total"],
)
print_status("Linear progress recorded", "success")
# Extract rich insights from session (LLM-powered analysis)
try:
extracted_insights = await extract_session_insights(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
session_num=session_num,
commit_before=commit_before,
commit_after=commit_after,
success=True,
recovery_manager=recovery_manager,
)
insight_count = len(extracted_insights.get("file_insights", []))
pattern_count = len(extracted_insights.get("patterns_discovered", []))
if insight_count > 0 or pattern_count > 0:
print_status(
f"Extracted {insight_count} file insights, {pattern_count} patterns",
"success",
)
except Exception as e:
logger.warning(f"Insight extraction failed: {e}")
extracted_insights = None
# Save session memory (Graphiti=primary, file-based=fallback)
try:
save_success, storage_type = await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
session_num=session_num,
success=True,
subtasks_completed=[subtask_id],
discoveries=extracted_insights,
)
if save_success:
if storage_type == "graphiti":
print_status("Session saved to Graphiti memory", "success")
else:
print_status(
"Session saved to file-based memory (fallback)", "info"
)
else:
print_status("Failed to save session memory", "warning")
except Exception as e:
logger.warning(f"Error saving session memory: {e}")
print_status("Memory save failed", "warning")
return True
elif subtask_status == "in_progress":
# Session ended without completion
print_status(f"Subtask {subtask_id} still in progress", "warning")
recovery_manager.record_attempt(
subtask_id=subtask_id,
session=session_num,
success=False,
approach="Session ended with subtask in_progress",
error="Subtask not marked as completed",
)
# Still record commit if one was made (partial progress)
if commit_after and commit_after != commit_before:
recovery_manager.record_good_commit(commit_after, subtask_id)
print_status(
f"Recorded partial progress commit: {commit_after[:8]}", "info"
)
# Record Linear session result (if enabled)
if linear_enabled:
attempt_count = recovery_manager.get_attempt_count(subtask_id)
await linear_subtask_failed(
spec_dir=spec_dir,
subtask_id=subtask_id,
attempt=attempt_count,
error_summary="Session ended without completion",
)
# Extract insights even from failed sessions (valuable for future attempts)
try:
extracted_insights = await extract_session_insights(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
session_num=session_num,
commit_before=commit_before,
commit_after=commit_after,
success=False,
recovery_manager=recovery_manager,
)
except Exception as e:
logger.debug(f"Insight extraction failed for incomplete session: {e}")
extracted_insights = None
# Save failed session memory (to track what didn't work)
try:
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
session_num=session_num,
success=False,
subtasks_completed=[],
discoveries=extracted_insights,
)
except Exception as e:
logger.debug(f"Failed to save incomplete session memory: {e}")
return False
else:
# Subtask still pending or failed
print_status(
f"Subtask {subtask_id} not completed (status: {subtask_status})", "error"
)
recovery_manager.record_attempt(
subtask_id=subtask_id,
session=session_num,
success=False,
approach="Session ended without progress",
error=f"Subtask status is {subtask_status}",
)
# Record Linear session result (if enabled)
if linear_enabled:
attempt_count = recovery_manager.get_attempt_count(subtask_id)
await linear_subtask_failed(
spec_dir=spec_dir,
subtask_id=subtask_id,
attempt=attempt_count,
error_summary=f"Subtask status: {subtask_status}",
)
# Extract insights even from completely failed sessions
try:
extracted_insights = await extract_session_insights(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
session_num=session_num,
commit_before=commit_before,
commit_after=commit_after,
success=False,
recovery_manager=recovery_manager,
)
except Exception as e:
logger.debug(f"Insight extraction failed for failed session: {e}")
extracted_insights = None
# Save failed session memory (to track what didn't work)
try:
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
session_num=session_num,
success=False,
subtasks_completed=[],
discoveries=extracted_insights,
)
except Exception as e:
logger.debug(f"Failed to save failed session memory: {e}")
return False
async def run_agent_session(
client: ClaudeSDKClient,
message: str,
spec_dir: Path,
verbose: bool = False,
phase: LogPhase = LogPhase.CODING,
) -> tuple[str, str]:
"""
Run a single agent session using Claude Agent SDK.
Args:
client: Claude SDK client
message: The prompt to send
spec_dir: Spec directory path
verbose: Whether to show detailed output
phase: Current execution phase for logging
Returns:
(status, response_text) where status is:
- "continue" if agent should continue working
- "complete" if all subtasks complete
- "error" if an error occurred
"""
debug_section("session", f"Agent Session - {phase.value}")
debug(
"session",
"Starting agent session",
spec_dir=str(spec_dir),
phase=phase.value,
prompt_length=len(message),
prompt_preview=message[:200] + "..." if len(message) > 200 else message,
)
print("Sending prompt to Claude Agent SDK...\n")
# Get task logger for this spec
task_logger = get_task_logger(spec_dir)
current_tool = None
message_count = 0
tool_count = 0
try:
# Send the query
debug("session", "Sending query to Claude SDK...")
await client.query(message)
debug_success("session", "Query sent successfully")
# Collect response text and show tool use
response_text = ""
debug("session", "Starting to receive response stream...")
async for msg in client.receive_response():
msg_type = type(msg).__name__
message_count += 1
debug_detailed(
"session",
f"Received message #{message_count}",
msg_type=msg_type,
)
# Handle AssistantMessage (text and tool use)
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
response_text += block.text
print(block.text, end="", flush=True)
# Log text to task logger (persist without double-printing)
if task_logger and block.text.strip():
task_logger.log(
block.text,
LogEntryType.TEXT,
phase,
print_to_console=False,
)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name
tool_input = None
tool_count += 1
# Extract meaningful tool input for display
if hasattr(block, "input") and block.input:
inp = block.input
if isinstance(inp, dict):
if "pattern" in inp:
tool_input = f"pattern: {inp['pattern']}"
elif "file_path" in inp:
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "command" in inp:
cmd = inp["command"]
if len(cmd) > 50:
cmd = cmd[:47] + "..."
tool_input = cmd
elif "path" in inp:
tool_input = inp["path"]
debug(
"session",
f"Tool call #{tool_count}: {tool_name}",
tool_input=tool_input,
full_input=str(block.input)[:500]
if hasattr(block, "input")
else None,
)
# Log tool start (handles printing too)
if task_logger:
task_logger.tool_start(
tool_name, tool_input, phase, print_to_console=True
)
else:
print(f"\n[Tool: {tool_name}]", flush=True)
if verbose and hasattr(block, "input"):
input_str = str(block.input)
if len(input_str) > 300:
print(f" Input: {input_str[:300]}...", flush=True)
else:
print(f" Input: {input_str}", flush=True)
current_tool = tool_name
# Handle UserMessage (tool results)
elif msg_type == "UserMessage" and hasattr(msg, "content"):
for block in msg.content:
block_type = type(block).__name__
if block_type == "ToolResultBlock":
result_content = getattr(block, "content", "")
is_error = getattr(block, "is_error", False)
# Check if command was blocked by security hook
if "blocked" in str(result_content).lower():
debug_error(
"session",
f"Tool BLOCKED: {current_tool}",
result=str(result_content)[:300],
)
print(f" [BLOCKED] {result_content}", flush=True)
if task_logger and current_tool:
task_logger.tool_end(
current_tool,
success=False,
result="BLOCKED",
detail=str(result_content),
phase=phase,
)
elif is_error:
# Show errors (truncated)
error_str = str(result_content)[:500]
debug_error(
"session",
f"Tool error: {current_tool}",
error=error_str[:200],
)
print(f" [Error] {error_str}", flush=True)
if task_logger and current_tool:
# Store full error in detail for expandable view
task_logger.tool_end(
current_tool,
success=False,
result=error_str[:100],
detail=str(result_content),
phase=phase,
)
else:
# Tool succeeded
debug_detailed(
"session",
f"Tool success: {current_tool}",
result_length=len(str(result_content)),
)
if verbose:
result_str = str(result_content)[:200]
print(f" [Done] {result_str}", flush=True)
else:
print(" [Done]", flush=True)
if task_logger and current_tool:
# Store full result in detail for expandable view (only for certain tools)
# Skip storing for very large outputs like Glob results
detail_content = None
if current_tool in (
"Read",
"Grep",
"Bash",
"Edit",
"Write",
):
result_str = str(result_content)
# Only store if not too large (detail truncation happens in logger)
if (
len(result_str) < 50000
): # 50KB max before truncation
detail_content = result_str
task_logger.tool_end(
current_tool,
success=True,
detail=detail_content,
phase=phase,
)
current_tool = None
print("\n" + "-" * 70 + "\n")
# Check if build is complete
if is_build_complete(spec_dir):
debug_success(
"session",
"Session completed - build is complete",
message_count=message_count,
tool_count=tool_count,
response_length=len(response_text),
)
return "complete", response_text
debug_success(
"session",
"Session completed - continuing",
message_count=message_count,
tool_count=tool_count,
response_length=len(response_text),
)
return "continue", response_text
except Exception as e:
debug_error(
"session",
f"Session error: {e}",
exception_type=type(e).__name__,
message_count=message_count,
tool_count=tool_count,
)
print(f"Error during agent session: {e}")
if task_logger:
task_logger.log_error(f"Session error: {e}", phase)
return "error", str(e)
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
Verification script for agent module refactoring.
This script verifies that:
1. All modules can be imported
2. All public API functions are accessible
3. Backwards compatibility is maintained
"""
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
def test_imports():
"""Test that all modules can be imported."""
print("Testing module imports...")
# Test base module
from agents import base
assert hasattr(base, "AUTO_CONTINUE_DELAY_SECONDS")
assert hasattr(base, "HUMAN_INTERVENTION_FILE")
print(" ✓ agents.base")
# Test utils module
from agents import utils
assert hasattr(utils, "get_latest_commit")
assert hasattr(utils, "load_implementation_plan")
print(" ✓ agents.utils")
# Test memory module
from agents import memory
assert hasattr(memory, "save_session_memory")
assert hasattr(memory, "get_graphiti_context")
print(" ✓ agents.memory")
# Test session module
from agents import session
assert hasattr(session, "run_agent_session")
assert hasattr(session, "post_session_processing")
print(" ✓ agents.session")
# Test planner module
from agents import planner
assert hasattr(planner, "run_followup_planner")
print(" ✓ agents.planner")
# Test coder module
from agents import coder
assert hasattr(coder, "run_autonomous_agent")
print(" ✓ agents.coder")
print("\n✓ All module imports successful!\n")
def test_public_api():
"""Test that the public API is accessible."""
print("Testing public API...")
# Test main agent module exports
import agents
required_functions = [
"run_autonomous_agent",
"run_followup_planner",
"save_session_memory",
"get_graphiti_context",
"run_agent_session",
"post_session_processing",
"get_latest_commit",
"load_implementation_plan",
]
for func_name in required_functions:
assert hasattr(agents, func_name), f"Missing function: {func_name}"
print(f" ✓ agents.{func_name}")
print("\n✓ All public API functions accessible!\n")
def test_backwards_compatibility():
"""Test that the old agent.py facade maintains backwards compatibility."""
print("Testing backwards compatibility...")
# Test that agent.py can be imported
import agent
required_functions = [
"run_autonomous_agent",
"run_followup_planner",
"save_session_memory",
"save_session_to_graphiti",
"run_agent_session",
"post_session_processing",
]
for func_name in required_functions:
assert hasattr(agent, func_name), (
f"Missing function in agent module: {func_name}"
)
print(f" ✓ agent.{func_name}")
print("\n✓ Backwards compatibility maintained!\n")
def test_module_structure():
"""Test that the module structure is correct."""
print("Testing module structure...")
from pathlib import Path
agents_dir = Path(__file__).parent
required_files = [
"__init__.py",
"base.py",
"utils.py",
"memory.py",
"session.py",
"planner.py",
"coder.py",
]
for filename in required_files:
filepath = agents_dir / filename
assert filepath.exists(), f"Missing file: {filename}"
print(f" ✓ agents/{filename}")
print("\n✓ Module structure correct!\n")
if __name__ == "__main__":
try:
test_module_structure()
test_imports()
test_public_api()
test_backwards_compatibility()
print("=" * 60)
print("✓ ALL TESTS PASSED - Refactoring verified!")
print("=" * 60)
except AssertionError as e:
print(f"\n✗ TEST FAILED: {e}")
sys.exit(1)
except ImportError as e:
print(f"\n✗ IMPORT ERROR: {e}")
print("Note: Some imports may fail due to missing dependencies.")
print("This is expected in test environments.")
sys.exit(0) # Don't fail on import errors (expected in test env)
+60
View File
@@ -0,0 +1,60 @@
"""
Custom MCP Tools for Auto-Claude Agents
========================================
This module provides custom MCP tools that agents can use for reliable
operations on auto-claude data structures. These tools replace prompt-based
JSON manipulation with guaranteed-correct operations.
Benefits:
- 100% reliable JSON operations (no malformed output)
- Reduced context usage (tool definitions << prompt instructions)
- Type-safe with proper error handling
- Each agent only sees tools relevant to their role via allowed_tools
Usage:
from auto_claude_tools import create_auto_claude_mcp_server, get_allowed_tools
# Create the MCP server
mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir)
# Get allowed tools for a specific agent type
allowed_tools = get_allowed_tools("coder")
# Use in ClaudeAgentOptions
options = ClaudeAgentOptions(
mcp_servers={"auto-claude": mcp_server},
allowed_tools=allowed_tools,
...
)
"""
from .models import (
ELECTRON_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,
)
from .permissions import get_allowed_tools
from .registry import create_auto_claude_mcp_server, is_tools_available
__all__ = [
# Main API
"create_auto_claude_mcp_server",
"get_allowed_tools",
"is_tools_available",
# 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",
"is_electron_mcp_enabled",
]
+63
View File
@@ -0,0 +1,63 @@
"""
Tool Models and Constants
==========================
Defines tool name constants and configuration for auto-claude MCP tools.
"""
import os
# =============================================================================
# Tool Name Constants
# =============================================================================
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
TOOL_UPDATE_SUBTASK_STATUS = "mcp__auto-claude__update_subtask_status"
TOOL_GET_BUILD_PROGRESS = "mcp__auto-claude__get_build_progress"
TOOL_RECORD_DISCOVERY = "mcp__auto-claude__record_discovery"
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"
# Puppeteer MCP tools for web browser automation
# Used for web frontend validation (non-Electron web apps)
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",
]
# 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.
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
]
# Base tools available to all agents
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
# =============================================================================
# Configuration
# =============================================================================
def is_electron_mcp_enabled() -> bool:
"""
Check if Electron MCP server integration is enabled.
Requires ELECTRON_MCP_ENABLED to be set to 'true'.
When enabled, QA agents can use Electron MCP tools to connect to Electron apps
via Chrome DevTools Protocol on the configured debug port.
"""
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
@@ -0,0 +1,147 @@
"""
Agent Tool Permissions
======================
Manages which tools are allowed for each agent type to prevent context
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.
"""
from .models import (
BASE_READ_TOOLS,
BASE_WRITE_TOOLS,
ELECTRON_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,
)
def get_allowed_tools(
agent_type: str,
project_capabilities: dict | None = None,
) -> list[str]:
"""
Get the list of allowed tools for a specific agent type.
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
Args:
agent_type: One of 'planner', 'coder', 'qa_reviewer', 'qa_fixer'
project_capabilities: Optional dict from detect_project_capabilities()
containing flags like is_electron, is_web_frontend, etc.
Returns:
List of allowed tool names
"""
# 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,
],
},
}
if agent_type not in tool_mappings:
# Default to coder tools
agent_type = "coder"
mapping = tool_mappings[agent_type]
tools = mapping["base"] + mapping["auto_claude"]
# 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))
return tools
def _get_qa_mcp_tools(project_capabilities: dict | None) -> list[str]:
"""
Get the list of MCP tools for QA agents based on project capabilities.
This function determines which MCP tools to include based on:
1. Project type detection (Electron, web frontend, etc.)
2. Environment variables (ELECTRON_MCP_ENABLED)
Args:
project_capabilities: Dict from detect_project_capabilities() or None
Returns:
List of MCP tool names to include
"""
tools = []
# If no capabilities provided, fall back to legacy behavior
# (check env var only)
if project_capabilities is None:
if is_electron_mcp_enabled():
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)
return tools
+72
View File
@@ -0,0 +1,72 @@
"""
Tool Registry
=============
Central registry for creating and managing auto-claude MCP tools.
"""
from pathlib import Path
try:
from claude_agent_sdk import create_sdk_mcp_server
SDK_TOOLS_AVAILABLE = True
except ImportError:
SDK_TOOLS_AVAILABLE = False
create_sdk_mcp_server = None
from .tools import (
create_memory_tools,
create_progress_tools,
create_qa_tools,
create_subtask_tools,
)
def create_all_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create all custom tools with the given spec and project directories.
Args:
spec_dir: Path to the spec directory
project_dir: Path to the project root
Returns:
List of all tool functions
"""
if not SDK_TOOLS_AVAILABLE:
return []
all_tools = []
# Create tools by category
all_tools.extend(create_subtask_tools(spec_dir, project_dir))
all_tools.extend(create_progress_tools(spec_dir, project_dir))
all_tools.extend(create_memory_tools(spec_dir, project_dir))
all_tools.extend(create_qa_tools(spec_dir, project_dir))
return all_tools
def create_auto_claude_mcp_server(spec_dir: Path, project_dir: Path):
"""
Create an MCP server with auto-claude custom tools.
Args:
spec_dir: Path to the spec directory
project_dir: Path to the project root
Returns:
MCP server instance, or None if SDK tools not available
"""
if not SDK_TOOLS_AVAILABLE:
return None
tools = create_all_tools(spec_dir, project_dir)
return create_sdk_mcp_server(name="auto-claude", version="1.0.0", tools=tools)
def is_tools_available() -> bool:
"""Check if SDK tools functionality is available."""
return SDK_TOOLS_AVAILABLE
@@ -0,0 +1,18 @@
"""
Auto-Claude MCP Tools
=====================
Individual tool implementations organized by functionality.
"""
from .memory import create_memory_tools
from .progress import create_progress_tools
from .qa import create_qa_tools
from .subtask import create_subtask_tools
__all__ = [
"create_subtask_tools",
"create_progress_tools",
"create_memory_tools",
"create_qa_tools",
]
@@ -0,0 +1,216 @@
"""
Session Memory Tools
====================
Tools for recording and retrieving session memory, including discoveries,
gotchas, and patterns.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
from claude_agent_sdk import tool
SDK_TOOLS_AVAILABLE = True
except ImportError:
SDK_TOOLS_AVAILABLE = False
tool = None
def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create session memory tools.
Args:
spec_dir: Path to the spec directory
project_dir: Path to the project root
Returns:
List of memory tool functions
"""
if not SDK_TOOLS_AVAILABLE:
return []
tools = []
# -------------------------------------------------------------------------
# Tool: record_discovery
# -------------------------------------------------------------------------
@tool(
"record_discovery",
"Record a codebase discovery to session memory. Use this when you learn something important about the codebase.",
{"file_path": str, "description": str, "category": str},
)
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
"""Record a discovery to the codebase map."""
file_path = args["file_path"]
description = args["description"]
category = args.get("category", "general")
memory_dir = spec_dir / "memory"
memory_dir.mkdir(exist_ok=True)
codebase_map_file = memory_dir / "codebase_map.json"
try:
# Load existing map or create new
if codebase_map_file.exists():
with open(codebase_map_file) as f:
codebase_map = json.load(f)
else:
codebase_map = {
"discovered_files": {},
"last_updated": None,
}
# Add or update the discovery
codebase_map["discovered_files"][file_path] = {
"description": description,
"category": category,
"discovered_at": datetime.now(timezone.utc).isoformat(),
}
codebase_map["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(codebase_map_file, "w") as f:
json.dump(codebase_map, f, indent=2)
return {
"content": [
{
"type": "text",
"text": f"Recorded discovery for '{file_path}': {description}",
}
]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error recording discovery: {e}"}]
}
tools.append(record_discovery)
# -------------------------------------------------------------------------
# Tool: record_gotcha
# -------------------------------------------------------------------------
@tool(
"record_gotcha",
"Record a gotcha or pitfall to avoid. Use this when you encounter something that future sessions should know.",
{"gotcha": str, "context": str},
)
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
"""Record a gotcha to session memory."""
gotcha = args["gotcha"]
context = args.get("context", "")
memory_dir = spec_dir / "memory"
memory_dir.mkdir(exist_ok=True)
gotchas_file = memory_dir / "gotchas.md"
try:
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
entry = f"\n## [{timestamp}]\n{gotcha}"
if context:
entry += f"\n\n_Context: {context}_"
entry += "\n"
with open(gotchas_file, "a") as f:
if not gotchas_file.exists() or gotchas_file.stat().st_size == 0:
f.write(
"# Gotchas & Pitfalls\n\nThings to watch out for in this codebase.\n"
)
f.write(entry)
return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error recording gotcha: {e}"}]
}
tools.append(record_gotcha)
# -------------------------------------------------------------------------
# Tool: get_session_context
# -------------------------------------------------------------------------
@tool(
"get_session_context",
"Get context from previous sessions including discoveries, gotchas, and patterns.",
{},
)
async def get_session_context(args: dict[str, Any]) -> dict[str, Any]:
"""Get accumulated session context."""
memory_dir = spec_dir / "memory"
if not memory_dir.exists():
return {
"content": [
{
"type": "text",
"text": "No session memory found. This appears to be the first session.",
}
]
}
result_parts = []
# Load codebase map
codebase_map_file = memory_dir / "codebase_map.json"
if codebase_map_file.exists():
try:
with open(codebase_map_file) as f:
codebase_map = json.load(f)
discoveries = codebase_map.get("discovered_files", {})
if discoveries:
result_parts.append("## Codebase Discoveries")
for path, info in list(discoveries.items())[:20]: # Limit to 20
desc = info.get("description", "No description")
result_parts.append(f"- `{path}`: {desc}")
except Exception:
pass
# Load gotchas
gotchas_file = memory_dir / "gotchas.md"
if gotchas_file.exists():
try:
content = gotchas_file.read_text()
if content.strip():
result_parts.append("\n## Gotchas")
# Take last 1000 chars to avoid too much context
result_parts.append(
content[-1000:] if len(content) > 1000 else content
)
except Exception:
pass
# Load patterns
patterns_file = memory_dir / "patterns.md"
if patterns_file.exists():
try:
content = patterns_file.read_text()
if content.strip():
result_parts.append("\n## Patterns")
result_parts.append(
content[-1000:] if len(content) > 1000 else content
)
except Exception:
pass
if not result_parts:
return {
"content": [
{"type": "text", "text": "No session context available yet."}
]
}
return {"content": [{"type": "text", "text": "\n".join(result_parts)}]}
tools.append(get_session_context)
return tools
@@ -0,0 +1,142 @@
"""
Build Progress Tools
====================
Tools for tracking and reporting build progress.
"""
import json
from pathlib import Path
from typing import Any
try:
from claude_agent_sdk import tool
SDK_TOOLS_AVAILABLE = True
except ImportError:
SDK_TOOLS_AVAILABLE = False
tool = None
def create_progress_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create build progress tracking tools.
Args:
spec_dir: Path to the spec directory
project_dir: Path to the project root
Returns:
List of progress tool functions
"""
if not SDK_TOOLS_AVAILABLE:
return []
tools = []
# -------------------------------------------------------------------------
# Tool: get_build_progress
# -------------------------------------------------------------------------
@tool(
"get_build_progress",
"Get the current build progress including completed subtasks, pending subtasks, and next subtask to work on.",
{},
)
async def get_build_progress(args: dict[str, Any]) -> dict[str, Any]:
"""Get current build progress."""
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return {
"content": [
{
"type": "text",
"text": "No implementation plan found. Run the planner first.",
}
]
}
try:
with open(plan_file) as f:
plan = json.load(f)
stats = {
"total": 0,
"completed": 0,
"in_progress": 0,
"pending": 0,
"failed": 0,
}
phases_summary = []
next_subtask = None
for phase in plan.get("phases", []):
phase_id = phase.get("id") or phase.get("phase")
phase_name = phase.get("name", phase_id)
phase_subtasks = phase.get("subtasks", [])
phase_stats = {"completed": 0, "total": len(phase_subtasks)}
for subtask in phase_subtasks:
stats["total"] += 1
status = subtask.get("status", "pending")
if status == "completed":
stats["completed"] += 1
phase_stats["completed"] += 1
elif status == "in_progress":
stats["in_progress"] += 1
elif status == "failed":
stats["failed"] += 1
else:
stats["pending"] += 1
# Track next subtask to work on
if next_subtask is None:
next_subtask = {
"id": subtask.get("id"),
"description": subtask.get("description"),
"phase": phase_name,
}
phases_summary.append(
f" {phase_name}: {phase_stats['completed']}/{phase_stats['total']}"
)
progress_pct = (
(stats["completed"] / stats["total"] * 100) if stats["total"] > 0 else 0
)
result = f"""Build Progress: {stats["completed"]}/{stats["total"]} subtasks ({progress_pct:.0f}%)
Status breakdown:
Completed: {stats["completed"]}
In Progress: {stats["in_progress"]}
Pending: {stats["pending"]}
Failed: {stats["failed"]}
Phases:
{chr(10).join(phases_summary)}"""
if next_subtask:
result += f"""
Next subtask to work on:
ID: {next_subtask["id"]}
Phase: {next_subtask["phase"]}
Description: {next_subtask["description"]}"""
elif stats["completed"] == stats["total"]:
result += "\n\nAll subtasks completed! Build is ready for QA."
return {"content": [{"type": "text", "text": result}]}
except Exception as e:
return {
"content": [
{"type": "text", "text": f"Error reading build progress: {e}"}
]
}
tools.append(get_build_progress)
return tools
+140
View File
@@ -0,0 +1,140 @@
"""
QA Management Tools
===================
Tools for managing QA status and sign-off in implementation_plan.json.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
from claude_agent_sdk import tool
SDK_TOOLS_AVAILABLE = True
except ImportError:
SDK_TOOLS_AVAILABLE = False
tool = None
def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create QA management tools.
Args:
spec_dir: Path to the spec directory
project_dir: Path to the project root
Returns:
List of QA tool functions
"""
if not SDK_TOOLS_AVAILABLE:
return []
tools = []
# -------------------------------------------------------------------------
# Tool: update_qa_status
# -------------------------------------------------------------------------
@tool(
"update_qa_status",
"Update the QA sign-off status in implementation_plan.json. Use after QA review.",
{"status": str, "issues": str, "tests_passed": str},
)
async def update_qa_status(args: dict[str, Any]) -> dict[str, Any]:
"""Update QA status in the implementation plan."""
status = args["status"]
issues_str = args.get("issues", "[]")
tests_str = args.get("tests_passed", "{}")
valid_statuses = [
"pending",
"in_review",
"approved",
"rejected",
"fixes_applied",
]
if status not in valid_statuses:
return {
"content": [
{
"type": "text",
"text": f"Error: Invalid QA status '{status}'. Must be one of: {valid_statuses}",
}
]
}
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return {
"content": [
{
"type": "text",
"text": "Error: implementation_plan.json not found",
}
]
}
try:
# Parse issues and tests
try:
issues = json.loads(issues_str) if issues_str else []
except json.JSONDecodeError:
issues = [{"description": issues_str}] if issues_str else []
try:
tests_passed = json.loads(tests_str) if tests_str else {}
except json.JSONDecodeError:
tests_passed = {}
with open(plan_file) as f:
plan = json.load(f)
# Get current QA session number
current_qa = plan.get("qa_signoff", {})
qa_session = current_qa.get("qa_session", 0)
if status in ["in_review", "rejected"]:
qa_session += 1
plan["qa_signoff"] = {
"status": status,
"qa_session": qa_session,
"issues_found": issues,
"tests_passed": tests_passed,
"timestamp": datetime.now(timezone.utc).isoformat(),
"ready_for_qa_revalidation": status == "fixes_applied",
}
# Update plan status to match QA result
# This ensures the UI shows the correct column after QA
if status == "approved":
plan["status"] = "human_review"
plan["planStatus"] = "review"
elif status == "rejected":
plan["status"] = "human_review"
plan["planStatus"] = "review"
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(plan_file, "w") as f:
json.dump(plan, f, indent=2)
return {
"content": [
{
"type": "text",
"text": f"Updated QA status to '{status}' (session {qa_session})",
}
]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error updating QA status: {e}"}]
}
tools.append(update_qa_status)
return tools
@@ -0,0 +1,135 @@
"""
Subtask Management Tools
========================
Tools for managing subtask status in implementation_plan.json.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
from claude_agent_sdk import tool
SDK_TOOLS_AVAILABLE = True
except ImportError:
SDK_TOOLS_AVAILABLE = False
tool = None
def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
"""
Create subtask management tools.
Args:
spec_dir: Path to the spec directory
project_dir: Path to the project root
Returns:
List of subtask tool functions
"""
if not SDK_TOOLS_AVAILABLE:
return []
tools = []
# -------------------------------------------------------------------------
# Tool: update_subtask_status
# -------------------------------------------------------------------------
@tool(
"update_subtask_status",
"Update the status of a subtask in implementation_plan.json. Use this when completing or starting a subtask.",
{"subtask_id": str, "status": str, "notes": str},
)
async def update_subtask_status(args: dict[str, Any]) -> dict[str, Any]:
"""Update subtask status in the implementation plan."""
subtask_id = args["subtask_id"]
status = args["status"]
notes = args.get("notes", "")
valid_statuses = ["pending", "in_progress", "completed", "failed"]
if status not in valid_statuses:
return {
"content": [
{
"type": "text",
"text": f"Error: Invalid status '{status}'. Must be one of: {valid_statuses}",
}
]
}
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return {
"content": [
{
"type": "text",
"text": "Error: implementation_plan.json not found",
}
]
}
try:
with open(plan_file) as f:
plan = json.load(f)
# Find and update the subtask
subtask_found = False
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
subtask["status"] = status
if notes:
subtask["notes"] = notes
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
subtask_found = True
break
if subtask_found:
break
if not subtask_found:
return {
"content": [
{
"type": "text",
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan",
}
]
}
# Update plan metadata
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(plan_file, "w") as f:
json.dump(plan, f, indent=2)
return {
"content": [
{
"type": "text",
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}'",
}
]
}
except json.JSONDecodeError as e:
return {
"content": [
{
"type": "text",
"text": f"Error: Invalid JSON in implementation_plan.json: {e}",
}
]
}
except Exception as e:
return {
"content": [
{"type": "text", "text": f"Error updating subtask status: {e}"}
]
}
tools.append(update_subtask_status)
return tools
+116
View File
@@ -0,0 +1,116 @@
"""
Utility Functions for Agent System
===================================
Helper functions for git operations, plan management, and file syncing.
"""
import json
import logging
import shutil
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
def get_latest_commit(project_dir: Path) -> str | None:
"""Get the hash of the latest git commit."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def get_commit_count(project_dir: Path) -> int:
"""Get the total number of commits."""
try:
result = subprocess.run(
["git", "rev-list", "--count", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
)
return int(result.stdout.strip())
except (subprocess.CalledProcessError, ValueError):
return 0
def load_implementation_plan(spec_dir: Path) -> dict | None:
"""Load the implementation plan JSON."""
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return None
try:
with open(plan_file) as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def find_subtask_in_plan(plan: dict, subtask_id: str) -> dict | None:
"""Find a subtask by ID in the plan."""
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
return subtask
return None
def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
"""Find the phase containing a subtask."""
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
return phase
return None
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
"""
Sync implementation_plan.json from worktree back to source spec directory.
When running in isolated mode (worktrees), the agent updates the implementation
plan inside the worktree. This function syncs those changes back to the main
project's spec directory so the frontend/UI can see the progress.
Args:
spec_dir: Current spec directory (may be inside worktree)
source_spec_dir: Original spec directory in main project (outside worktree)
Returns:
True if sync was performed, False if not needed or failed
"""
# Skip if no source specified or same path (not in worktree mode)
if not source_spec_dir:
return False
# Resolve paths and check if they're different
spec_dir_resolved = spec_dir.resolve()
source_spec_dir_resolved = source_spec_dir.resolve()
if spec_dir_resolved == source_spec_dir_resolved:
return False # Same directory, no sync needed
# Sync the implementation plan
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return False
source_plan_file = source_spec_dir / "implementation_plan.json"
try:
shutil.copy2(plan_file, source_plan_file)
logger.debug(f"Synced implementation plan to source: {source_plan_file}")
return True
except Exception as e:
logger.warning(f"Failed to sync implementation plan to source: {e}")
return False
+41
View File
@@ -0,0 +1,41 @@
"""
Analysis Module
===============
Code analysis and project scanning tools.
"""
# Import from analyzers subpackage (these are the modular analyzers)
from __future__ import annotations
from .analyzers import (
ProjectAnalyzer as ModularProjectAnalyzer,
)
from .analyzers import (
ServiceAnalyzer,
analyze_project,
analyze_service,
)
from .ci_discovery import CIDiscovery
# Import from analysis module root (these are other analysis tools)
from .project_analyzer import ProjectAnalyzer
from .risk_classifier import RiskClassifier
from .security_scanner import SecurityScanner
from .test_discovery import TestDiscovery
# insight_extractor is a module with functions, not a class, so don't import it here
# Import it directly when needed: from analysis import insight_extractor
__all__ = [
"ProjectAnalyzer",
"ModularProjectAnalyzer",
"ServiceAnalyzer",
"analyze_project",
"analyze_service",
"RiskClassifier",
"SecurityScanner",
"CIDiscovery",
"TestDiscovery",
]
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Codebase Analyzer
=================
Automatically detects project structure, frameworks, and services.
Supports monorepos with multiple services.
Usage:
# Index entire project (creates project_index.json)
python auto-claude/analyzer.py --index
# Analyze specific service
python auto-claude/analyzer.py --service backend
# Output to specific file
python auto-claude/analyzer.py --index --output path/to/output.json
The analyzer will:
1. Detect if this is a monorepo or single project
2. Find all services/packages and analyze each separately
3. Map interdependencies between services
4. Identify infrastructure (Docker, CI/CD)
5. Document conventions (linting, testing)
This module now serves as a facade to the modular analyzer system in the analyzers/ package.
All actual implementation is in focused submodules for better maintainability.
"""
from __future__ import annotations
import json
from pathlib import Path
# Import from the new modular structure
from .analyzers import (
ProjectAnalyzer,
ServiceAnalyzer,
analyze_project,
analyze_service,
)
# Re-export for backward compatibility
__all__ = [
"ServiceAnalyzer",
"ProjectAnalyzer",
"analyze_project",
"analyze_service",
]
def main():
"""CLI entry point."""
import argparse
parser = argparse.ArgumentParser(
description="Analyze project structure, frameworks, and services"
)
parser.add_argument(
"--project-dir",
type=Path,
default=Path.cwd(),
help="Project directory to analyze (default: current directory)",
)
parser.add_argument(
"--index",
action="store_true",
help="Create full project index (default behavior)",
)
parser.add_argument(
"--service",
type=str,
default=None,
help="Analyze a specific service only",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Output file for JSON results",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Only output JSON, no status messages",
)
args = parser.parse_args()
# Determine what to analyze
if args.service:
results = analyze_service(args.project_dir, args.service, args.output)
else:
results = analyze_project(args.project_dir, args.output)
# Print results
if not args.quiet or not args.output:
print(json.dumps(results, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,94 @@
"""
Analyzers Package
=================
Modular analyzer system for detecting project structure, frameworks, and services.
Main exports:
- ServiceAnalyzer: Analyzes a single service/package
- ProjectAnalyzer: Analyzes entire projects (single or monorepo)
- analyze_project: Convenience function for project analysis
- analyze_service: Convenience function for service analysis
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .project_analyzer_module import ProjectAnalyzer
from .service_analyzer import ServiceAnalyzer
# Re-export main classes
__all__ = [
"ServiceAnalyzer",
"ProjectAnalyzer",
"analyze_project",
"analyze_service",
]
def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict:
"""
Analyze a project and optionally save results.
Args:
project_dir: Path to the project root
output_file: Optional path to save JSON output
Returns:
Project index as a dictionary
"""
import json
analyzer = ProjectAnalyzer(project_dir)
results = analyzer.analyze()
if output_file:
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w") as f:
json.dump(results, f, indent=2)
print(f"Project index saved to: {output_file}")
return results
def analyze_service(
project_dir: Path, service_name: str, output_file: Path | None = None
) -> dict:
"""
Analyze a specific service within a project.
Args:
project_dir: Path to the project root
service_name: Name of the service to analyze
output_file: Optional path to save JSON output
Returns:
Service analysis as a dictionary
"""
import json
# Find the service
service_path = project_dir / service_name
if not service_path.exists():
# Check common locations
for parent in ["packages", "apps", "services"]:
candidate = project_dir / parent / service_name
if candidate.exists():
service_path = candidate
break
if not service_path.exists():
raise ValueError(f"Service '{service_name}' not found in {project_dir}")
analyzer = ServiceAnalyzer(service_path, service_name)
results = analyzer.analyze()
if output_file:
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w") as f:
json.dump(results, f, indent=2)
print(f"Service analysis saved to: {output_file}")
return results
+151
View File
@@ -0,0 +1,151 @@
"""
Base Analyzer Module
====================
Provides common constants, utilities, and base functionality shared across all analyzers.
"""
from __future__ import annotations
import json
from pathlib import Path
# Directories to skip during analysis
SKIP_DIRS = {
"node_modules",
".git",
"__pycache__",
".venv",
"venv",
".env",
"env",
"dist",
"build",
".next",
".nuxt",
"target",
"vendor",
".idea",
".vscode",
".pytest_cache",
".mypy_cache",
"coverage",
".coverage",
"htmlcov",
"eggs",
"*.egg-info",
".turbo",
".cache",
".worktrees", # Skip git worktrees directory
".auto-claude", # Skip auto-claude metadata directory
}
# Common service directory names
SERVICE_INDICATORS = {
"backend",
"frontend",
"api",
"web",
"app",
"server",
"client",
"worker",
"workers",
"services",
"packages",
"apps",
"libs",
"scraper",
"crawler",
"proxy",
"gateway",
"admin",
"dashboard",
"mobile",
"desktop",
"cli",
"sdk",
"core",
"shared",
"common",
}
# Files that indicate a service root
SERVICE_ROOT_FILES = {
"package.json",
"requirements.txt",
"pyproject.toml",
"Cargo.toml",
"go.mod",
"Gemfile",
"composer.json",
"pom.xml",
"build.gradle",
"Makefile",
"Dockerfile",
}
class BaseAnalyzer:
"""Base class with common utilities for all analyzers."""
def __init__(self, path: Path):
self.path = path.resolve()
def _exists(self, path: str) -> bool:
"""Check if a file exists relative to the analyzer's path."""
return (self.path / path).exists()
def _read_file(self, path: str) -> str:
"""Read a file relative to the analyzer's path."""
try:
return (self.path / path).read_text()
except (OSError, UnicodeDecodeError):
return ""
def _read_json(self, path: str) -> dict | None:
"""Read and parse a JSON file relative to the analyzer's path."""
content = self._read_file(path)
if content:
try:
return json.loads(content)
except json.JSONDecodeError:
return None
return None
def _infer_env_var_type(self, value: str) -> str:
"""Infer the type of an environment variable from its value."""
if not value:
return "string"
# Boolean
if value.lower() in ["true", "false", "1", "0", "yes", "no"]:
return "boolean"
# Number
if value.isdigit():
return "number"
# URL
if value.startswith(
(
"http://",
"https://",
"postgres://",
"postgresql://",
"mysql://",
"mongodb://",
"redis://",
)
):
return "url"
# Email
if "@" in value and "." in value:
return "email"
# Path
if "/" in value or "\\" in value:
return "path"
return "string"
@@ -0,0 +1,26 @@
"""
Context Analyzer Package
=========================
Contains specialized detectors for comprehensive project context analysis.
"""
from __future__ import annotations
from .api_docs_detector import ApiDocsDetector
from .auth_detector import AuthDetector
from .env_detector import EnvironmentDetector
from .jobs_detector import JobsDetector
from .migrations_detector import MigrationsDetector
from .monitoring_detector import MonitoringDetector
from .services_detector import ServicesDetector
__all__ = [
"ApiDocsDetector",
"AuthDetector",
"EnvironmentDetector",
"JobsDetector",
"MigrationsDetector",
"MonitoringDetector",
"ServicesDetector",
]
@@ -0,0 +1,95 @@
"""
API Documentation Detector Module
==================================
Detects API documentation tools and configurations:
- OpenAPI/Swagger (FastAPI auto-generated, swagger-ui-express)
- GraphQL playground
- API documentation endpoints
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..base import BaseAnalyzer
class ApiDocsDetector(BaseAnalyzer):
"""Detects API documentation setup."""
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect(self) -> None:
"""
Detect API documentation setup.
Detects: OpenAPI/Swagger, GraphQL playground, API docs endpoints.
"""
docs_info = {}
# Detect OpenAPI/Swagger
openapi_info = self._detect_fastapi() or self._detect_swagger_nodejs()
if openapi_info:
docs_info.update(openapi_info)
# Detect GraphQL
graphql_info = self._detect_graphql()
if graphql_info:
docs_info["graphql"] = graphql_info
if docs_info:
self.analysis["api_documentation"] = docs_info
def _detect_fastapi(self) -> dict[str, Any] | None:
"""Detect FastAPI auto-generated OpenAPI docs."""
if self.analysis.get("framework") != "FastAPI":
return None
return {
"type": "openapi",
"auto_generated": True,
"docs_url": "/docs",
"redoc_url": "/redoc",
"openapi_url": "/openapi.json",
}
def _detect_swagger_nodejs(self) -> dict[str, Any] | None:
"""Detect Swagger for Node.js projects."""
if not self._exists("package.json"):
return None
pkg = self._read_json("package.json")
if not pkg:
return None
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
if "swagger-ui-express" in deps or "swagger-jsdoc" in deps:
return {
"type": "openapi",
"library": "swagger-ui-express",
"docs_url": "/api-docs",
}
return None
def _detect_graphql(self) -> dict[str, str] | None:
"""Detect GraphQL API and playground."""
if not self._exists("package.json"):
return None
pkg = self._read_json("package.json")
if not pkg:
return None
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
if "graphql" in deps or "apollo-server" in deps or "@apollo/server" in deps:
return {
"playground_url": "/graphql",
"library": "apollo-server" if "apollo-server" in deps else "graphql",
}
return None
@@ -0,0 +1,141 @@
"""
Authentication Patterns Detector Module
========================================
Detects authentication and authorization patterns:
- JWT authentication
- OAuth providers
- Session-based authentication
- API key authentication
- User models
- Auth middleware and decorators
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from ..base import BaseAnalyzer
class AuthDetector(BaseAnalyzer):
"""Detects authentication and authorization patterns."""
JWT_LIBS = ["python-jose", "pyjwt", "jsonwebtoken", "jose"]
OAUTH_LIBS = ["authlib", "passport", "next-auth", "@auth/core", "oauth2"]
SESSION_LIBS = ["flask-login", "express-session", "django.contrib.auth"]
USER_MODEL_FILES = [
"models/user.py",
"models/User.py",
"app/models/user.py",
"models/user.ts",
"models/User.ts",
"src/models/user.ts",
]
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect(self) -> None:
"""
Detect authentication and authorization patterns.
Detects: JWT, OAuth, session-based, API keys, user models, protected routes.
"""
auth_info = {
"strategies": [],
"libraries": [],
"user_model": None,
"middleware": [],
}
# Get all dependencies
all_deps = self._get_all_dependencies()
# Detect auth strategies and libraries
self._detect_jwt(all_deps, auth_info)
self._detect_oauth(all_deps, auth_info)
self._detect_session(all_deps, auth_info)
# Find user model
auth_info["user_model"] = self._find_user_model()
# Detect auth middleware/decorators
auth_info["middleware"] = self._find_auth_middleware()
# Remove duplicates from strategies
auth_info["strategies"] = list(set(auth_info["strategies"]))
if auth_info["strategies"] or auth_info["libraries"]:
self.analysis["auth"] = auth_info
def _get_all_dependencies(self) -> set[str]:
"""Extract all dependencies from Python and Node.js projects."""
all_deps = set()
if self._exists("requirements.txt"):
content = self._read_file("requirements.txt")
all_deps.update(re.findall(r"^([a-zA-Z0-9_-]+)", content, re.MULTILINE))
pkg = self._read_json("package.json")
if pkg:
all_deps.update(pkg.get("dependencies", {}).keys())
return all_deps
def _detect_jwt(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
"""Detect JWT authentication libraries."""
for lib in self.JWT_LIBS:
if lib in all_deps:
auth_info["strategies"].append("jwt")
auth_info["libraries"].append(lib)
break
def _detect_oauth(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
"""Detect OAuth authentication libraries."""
for lib in self.OAUTH_LIBS:
if lib in all_deps:
auth_info["strategies"].append("oauth")
auth_info["libraries"].append(lib)
break
def _detect_session(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
"""Detect session-based authentication libraries."""
for lib in self.SESSION_LIBS:
if lib in all_deps:
auth_info["strategies"].append("session")
auth_info["libraries"].append(lib)
break
def _find_user_model(self) -> str | None:
"""Find the user model file."""
for model_file in self.USER_MODEL_FILES:
if self._exists(model_file):
return model_file
return None
def _find_auth_middleware(self) -> list[str]:
"""Detect auth middleware and decorators from Python files."""
# Limit to first 20 files for performance
all_py_files = list(self.path.glob("**/*.py"))[:20]
auth_decorators = set()
for py_file in all_py_files:
try:
content = py_file.read_text()
# Find custom decorators
if (
"@require" in content
or "@login_required" in content
or "@authenticate" in content
):
decorators = re.findall(r"@(\w*(?:require|auth|login)\w*)", content)
auth_decorators.update(decorators)
except (OSError, UnicodeDecodeError):
continue
return list(auth_decorators) if auth_decorators else []
@@ -0,0 +1,223 @@
"""
Environment Variable Detector Module
=====================================
Detects and analyzes environment variables from multiple sources:
- .env files and variants
- .env.example files
- docker-compose.yml
- Source code (os.getenv, process.env)
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from ..base import BaseAnalyzer
class EnvironmentDetector(BaseAnalyzer):
"""Detects environment variables and their configurations."""
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect(self) -> None:
"""
Discover all environment variables from multiple sources.
Extracts from: .env files, docker-compose, example files.
Categorizes as required/optional and detects sensitive data.
"""
env_vars = {}
required_vars = set()
optional_vars = set()
# Parse various sources
self._parse_env_files(env_vars)
self._parse_env_example(env_vars, required_vars)
self._parse_docker_compose(env_vars)
self._parse_code_references(env_vars, optional_vars)
# Mark required vs optional
for key in env_vars:
if "required" not in env_vars[key]:
env_vars[key]["required"] = key in required_vars
if env_vars:
self.analysis["environment"] = {
"variables": env_vars,
"required_count": len(required_vars),
"optional_count": len(optional_vars),
"detected_count": len(env_vars),
}
def _parse_env_files(self, env_vars: dict[str, Any]) -> None:
"""Parse .env files and variants."""
env_files = [
".env",
".env.local",
".env.development",
".env.production",
".env.dev",
".env.prod",
".env.test",
".env.staging",
"config/.env",
"../.env",
]
for env_file in env_files:
content = self._read_file(env_file)
if not content:
continue
for line in content.split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
# Parse KEY=value or KEY="value" or KEY='value'
match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$", line)
if match:
key = match.group(1)
value = match.group(2).strip().strip('"').strip("'")
# Detect if sensitive
is_sensitive = self._is_sensitive_key(key)
# Detect type
var_type = self._infer_env_var_type(value)
env_vars[key] = {
"value": "<REDACTED>" if is_sensitive else value,
"source": env_file,
"type": var_type,
"sensitive": is_sensitive,
}
def _parse_env_example(
self, env_vars: dict[str, Any], required_vars: set[str]
) -> None:
"""Parse .env.example to find required variables."""
example_content = self._read_file(".env.example") or self._read_file(
".env.sample"
)
if not example_content:
return
for line in example_content.split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=", line)
if match:
key = match.group(1)
required_vars.add(key)
if key not in env_vars:
env_vars[key] = {
"value": None,
"source": ".env.example",
"type": "string",
"sensitive": self._is_sensitive_key(key),
"required": True,
}
def _parse_docker_compose(self, env_vars: dict[str, Any]) -> None:
"""Parse docker-compose.yml environment section."""
for compose_file in ["docker-compose.yml", "../docker-compose.yml"]:
content = self._read_file(compose_file)
if not content:
continue
# Look for environment variables in docker-compose
in_env_section = False
for line in content.split("\n"):
if "environment:" in line:
in_env_section = True
continue
if in_env_section:
# Check if we left the environment section
if line and not line.startswith((" ", "\t", "-")):
in_env_section = False
continue
# Parse - KEY=value or - KEY
match = re.match(r"^\s*-\s*([A-Z_][A-Z0-9_]*)", line)
if match:
key = match.group(1)
if key not in env_vars:
env_vars[key] = {
"value": None,
"source": compose_file,
"type": "string",
"sensitive": False,
}
def _parse_code_references(
self, env_vars: dict[str, Any], optional_vars: set[str]
) -> None:
"""Scan code for os.getenv() / process.env usage to find optional vars."""
entry_files = [
"app.py",
"main.py",
"config.py",
"settings.py",
"src/config.py",
"src/settings.py",
"index.js",
"index.ts",
"config.js",
"config.ts",
]
for entry_file in entry_files:
content = self._read_file(entry_file)
if not content:
continue
# Python: os.getenv("VAR") or os.environ.get("VAR")
python_patterns = [
r'os\.getenv\(["\']([A-Z_][A-Z0-9_]*)["\']',
r'os\.environ\.get\(["\']([A-Z_][A-Z0-9_]*)["\']',
r'os\.environ\[["\']([A-Z_][A-Z0-9_]*)["\']',
]
# JavaScript: process.env.VAR
js_patterns = [
r"process\.env\.([A-Z_][A-Z0-9_]*)",
]
for pattern in python_patterns + js_patterns:
matches = re.findall(pattern, content)
for var_name in matches:
if var_name not in env_vars:
optional_vars.add(var_name)
env_vars[var_name] = {
"value": None,
"source": f"code:{entry_file}",
"type": "string",
"sensitive": self._is_sensitive_key(var_name),
"required": False,
}
@staticmethod
def _is_sensitive_key(key: str) -> bool:
"""Determine if an environment variable key contains sensitive data."""
sensitive_keywords = [
"secret",
"key",
"password",
"token",
"api_key",
"private",
"credential",
"auth",
]
return any(keyword in key.lower() for keyword in sensitive_keywords)
@@ -0,0 +1,118 @@
"""
Background Jobs Detector Module
================================
Detects background job and task queue systems:
- Celery (Python)
- BullMQ/Bull (Node.js)
- Sidekiq (Ruby)
- Scheduled tasks and cron jobs
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from ..base import BaseAnalyzer
class JobsDetector(BaseAnalyzer):
"""Detects background job and task queue systems."""
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect(self) -> None:
"""
Detect background job/task queue systems.
Detects: Celery, BullMQ, Sidekiq, cron jobs, scheduled tasks.
"""
jobs_info = None
# Try each job system in order
jobs_info = (
self._detect_celery() or self._detect_bullmq() or self._detect_sidekiq()
)
if jobs_info:
self.analysis["background_jobs"] = jobs_info
def _detect_celery(self) -> dict[str, Any] | None:
"""Detect Celery (Python) task queue."""
celery_files = list(self.path.glob("**/celery.py")) + list(
self.path.glob("**/tasks.py")
)
if not celery_files:
return None
tasks = []
for task_file in celery_files:
try:
content = task_file.read_text()
# Find @celery.task or @shared_task decorators
task_pattern = r"@(?:celery\.task|shared_task|app\.task)\s*(?:\([^)]*\))?\s*def\s+(\w+)"
task_matches = re.findall(task_pattern, content)
for task_name in task_matches:
tasks.append(
{
"name": task_name,
"file": str(task_file.relative_to(self.path)),
}
)
except (OSError, UnicodeDecodeError):
continue
if not tasks:
return None
return {
"system": "celery",
"tasks": tasks,
"total_tasks": len(tasks),
"worker_command": "celery -A app worker",
}
def _detect_bullmq(self) -> dict[str, Any] | None:
"""Detect BullMQ/Bull (Node.js) task queue."""
if not self._exists("package.json"):
return None
pkg = self._read_json("package.json")
if not pkg:
return None
deps = pkg.get("dependencies", {})
if "bullmq" in deps:
return {
"system": "bullmq",
"tasks": [],
"worker_command": "node worker.js",
}
elif "bull" in deps:
return {
"system": "bull",
"tasks": [],
"worker_command": "node worker.js",
}
return None
def _detect_sidekiq(self) -> dict[str, Any] | None:
"""Detect Sidekiq (Ruby) background jobs."""
if not self._exists("Gemfile"):
return None
gemfile = self._read_file("Gemfile")
if "sidekiq" not in gemfile.lower():
return None
return {
"system": "sidekiq",
"worker_command": "bundle exec sidekiq",
}
@@ -0,0 +1,129 @@
"""
Database Migrations Detector Module
====================================
Detects database migration tools and configurations:
- Alembic (Python)
- Django migrations
- Knex (Node.js)
- TypeORM
- Prisma
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..base import BaseAnalyzer
class MigrationsDetector(BaseAnalyzer):
"""Detects database migration setup and tools."""
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect(self) -> None:
"""
Detect database migration setup.
Detects: Alembic, Django migrations, Knex, TypeORM, Prisma migrations.
"""
migration_info = None
# Try each migration tool in order
migration_info = (
self._detect_alembic()
or self._detect_django()
or self._detect_knex()
or self._detect_typeorm()
or self._detect_prisma()
)
if migration_info:
self.analysis["migrations"] = migration_info
def _detect_alembic(self) -> dict[str, Any] | None:
"""Detect Alembic (Python) migrations."""
if not (self._exists("alembic.ini") or self._exists("alembic")):
return None
return {
"tool": "alembic",
"directory": "alembic/versions"
if self._exists("alembic/versions")
else "alembic",
"config_file": "alembic.ini",
"commands": {
"upgrade": "alembic upgrade head",
"downgrade": "alembic downgrade -1",
"create": "alembic revision --autogenerate -m 'message'",
},
}
def _detect_django(self) -> dict[str, Any] | None:
"""Detect Django migrations."""
if not self._exists("manage.py"):
return None
migration_dirs = list(self.path.glob("**/migrations"))
if not migration_dirs:
return None
return {
"tool": "django",
"directories": [str(d.relative_to(self.path)) for d in migration_dirs],
"commands": {
"migrate": "python manage.py migrate",
"makemigrations": "python manage.py makemigrations",
},
}
def _detect_knex(self) -> dict[str, Any] | None:
"""Detect Knex (Node.js) migrations."""
if not (self._exists("knexfile.js") or self._exists("knexfile.ts")):
return None
return {
"tool": "knex",
"directory": "migrations",
"config_file": "knexfile.js",
"commands": {
"migrate": "knex migrate:latest",
"rollback": "knex migrate:rollback",
"create": "knex migrate:make migration_name",
},
}
def _detect_typeorm(self) -> dict[str, Any] | None:
"""Detect TypeORM migrations."""
if not (self._exists("ormconfig.json") or self._exists("data-source.ts")):
return None
return {
"tool": "typeorm",
"directory": "migrations",
"commands": {
"run": "typeorm migration:run",
"revert": "typeorm migration:revert",
"create": "typeorm migration:create",
},
}
def _detect_prisma(self) -> dict[str, Any] | None:
"""Detect Prisma migrations."""
if not self._exists("prisma/schema.prisma"):
return None
return {
"tool": "prisma",
"directory": "prisma/migrations",
"config_file": "prisma/schema.prisma",
"commands": {
"migrate": "prisma migrate deploy",
"dev": "prisma migrate dev",
"create": "prisma migrate dev --name migration_name",
},
}
@@ -0,0 +1,109 @@
"""
Monitoring Detector Module
===========================
Detects monitoring and observability setup:
- Health check endpoints
- Prometheus metrics endpoints
- APM tools (Sentry, Datadog, New Relic)
- Logging infrastructure
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..base import BaseAnalyzer
class MonitoringDetector(BaseAnalyzer):
"""Detects monitoring and observability setup."""
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect(self) -> None:
"""
Detect monitoring and observability setup.
Detects: Health checks, metrics endpoints, APM tools, logging.
"""
monitoring_info = {}
# Detect health check endpoints from existing API analysis
health_checks = self._detect_health_checks()
if health_checks:
monitoring_info["health_checks"] = health_checks
# Detect Prometheus metrics
metrics_info = self._detect_prometheus()
if metrics_info:
monitoring_info.update(metrics_info)
# Reference APM tools from services analysis
apm_tools = self._get_apm_tools()
if apm_tools:
monitoring_info["apm_tools"] = apm_tools
if monitoring_info:
self.analysis["monitoring"] = monitoring_info
def _detect_health_checks(self) -> list[str] | None:
"""Detect health check endpoints from API routes."""
if "api" not in self.analysis:
return None
routes = self.analysis["api"].get("routes", [])
health_routes = [
r["path"]
for r in routes
if "health" in r["path"].lower() or "ping" in r["path"].lower()
]
return health_routes if health_routes else None
def _detect_prometheus(self) -> dict[str, str] | None:
"""Detect Prometheus metrics endpoint."""
# Look for actual Prometheus imports/usage, not just keywords
all_files = (
list(self.path.glob("**/*.py"))[:30] + list(self.path.glob("**/*.js"))[:30]
)
for file_path in all_files:
# Skip analyzer files to avoid self-detection
if "analyzers" in str(file_path) or "analyzer.py" in str(file_path):
continue
try:
content = file_path.read_text()
# Look for actual Prometheus imports or usage patterns
prometheus_patterns = [
"from prometheus_client import",
"import prometheus_client",
"prometheus_client.",
"@app.route('/metrics')", # Flask
"app.get('/metrics'", # Express/Fastify
"router.get('/metrics'", # Express Router
]
if any(pattern in content for pattern in prometheus_patterns):
return {
"metrics_endpoint": "/metrics",
"metrics_type": "prometheus",
}
except (OSError, UnicodeDecodeError):
continue
return None
def _get_apm_tools(self) -> list[str] | None:
"""Get APM tools from existing services analysis."""
if (
"services" not in self.analysis
or "monitoring" not in self.analysis["services"]
):
return None
return [s["type"] for s in self.analysis["services"]["monitoring"]]
@@ -0,0 +1,215 @@
"""
External Services Detector Module
==================================
Detects external service integrations based on dependencies:
- Databases (PostgreSQL, MySQL, MongoDB, Redis, SQLite)
- Cache services (Redis, Memcached)
- Message queues (Celery, BullMQ, Kafka, RabbitMQ)
- Email services (SendGrid, Mailgun, Postmark)
- Payment processors (Stripe, PayPal, Square)
- Storage services (AWS S3, Google Cloud Storage, Azure)
- Auth providers (OAuth, JWT)
- Monitoring tools (Sentry, Datadog, New Relic)
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from ..base import BaseAnalyzer
class ServicesDetector(BaseAnalyzer):
"""Detects external service integrations."""
# Service indicator mappings
DATABASE_INDICATORS = {
"psycopg2": "postgresql",
"psycopg2-binary": "postgresql",
"pg": "postgresql",
"mysql": "mysql",
"mysql2": "mysql",
"pymongo": "mongodb",
"mongodb": "mongodb",
"mongoose": "mongodb",
"redis": "redis",
"redis-py": "redis",
"ioredis": "redis",
"sqlite3": "sqlite",
"better-sqlite3": "sqlite",
}
CACHE_INDICATORS = ["redis", "memcached", "node-cache"]
QUEUE_INDICATORS = {
"celery": "celery",
"bullmq": "bullmq",
"bull": "bull",
"kafka-python": "kafka",
"kafkajs": "kafka",
"amqplib": "rabbitmq",
"amqp": "rabbitmq",
}
EMAIL_INDICATORS = {
"sendgrid": "sendgrid",
"@sendgrid/mail": "sendgrid",
"nodemailer": "smtp",
"mailgun": "mailgun",
"postmark": "postmark",
}
PAYMENT_INDICATORS = {
"stripe": "stripe",
"paypal": "paypal",
"square": "square",
"braintree": "braintree",
}
STORAGE_INDICATORS = {
"boto3": "aws_s3",
"@aws-sdk/client-s3": "aws_s3",
"aws-sdk": "aws_s3",
"@google-cloud/storage": "google_cloud_storage",
"azure-storage-blob": "azure_blob_storage",
}
AUTH_INDICATORS = {
"authlib": "oauth",
"python-jose": "jwt",
"pyjwt": "jwt",
"jsonwebtoken": "jwt",
"passport": "oauth",
"next-auth": "oauth",
"@auth/core": "oauth",
}
MONITORING_INDICATORS = {
"sentry-sdk": "sentry",
"@sentry/node": "sentry",
"datadog": "datadog",
"newrelic": "new_relic",
"loguru": "logging",
"winston": "logging",
"pino": "logging",
}
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect(self) -> None:
"""
Detect external service integrations.
Detects: databases, cache, email, payments, storage, monitoring, etc.
"""
services = {
"databases": [],
"cache": [],
"message_queues": [],
"email": [],
"payments": [],
"storage": [],
"auth_providers": [],
"monitoring": [],
}
# Get all dependencies
all_deps = self._get_all_dependencies()
# Detect each service category
self._detect_databases(all_deps, services["databases"])
self._detect_cache(all_deps, services["cache"])
self._detect_message_queues(all_deps, services["message_queues"])
self._detect_email(all_deps, services["email"])
self._detect_payments(all_deps, services["payments"])
self._detect_storage(all_deps, services["storage"])
self._detect_auth_providers(all_deps, services["auth_providers"])
self._detect_monitoring(all_deps, services["monitoring"])
# Remove empty categories
services = {k: v for k, v in services.items() if v}
if services:
self.analysis["services"] = services
def _get_all_dependencies(self) -> set[str]:
"""Extract all dependencies from Python and Node.js projects."""
all_deps = set()
# Python dependencies
if self._exists("requirements.txt"):
content = self._read_file("requirements.txt")
all_deps.update(re.findall(r"^([a-zA-Z0-9_-]+)", content, re.MULTILINE))
# Node.js dependencies
pkg = self._read_json("package.json")
if pkg:
all_deps.update(pkg.get("dependencies", {}).keys())
all_deps.update(pkg.get("devDependencies", {}).keys())
return all_deps
def _detect_databases(
self, all_deps: set[str], databases: list[dict[str, str]]
) -> None:
"""Detect database clients."""
for dep, db_type in self.DATABASE_INDICATORS.items():
if dep in all_deps:
databases.append({"type": db_type, "client": dep})
def _detect_cache(self, all_deps: set[str], cache: list[dict[str, str]]) -> None:
"""Detect cache services."""
for indicator in self.CACHE_INDICATORS:
if indicator in all_deps:
cache.append({"type": indicator})
def _detect_message_queues(
self, all_deps: set[str], queues: list[dict[str, str]]
) -> None:
"""Detect message queue systems."""
for dep, queue_type in self.QUEUE_INDICATORS.items():
if dep in all_deps:
queues.append({"type": queue_type, "client": dep})
def _detect_email(self, all_deps: set[str], email: list[dict[str, str]]) -> None:
"""Detect email service providers."""
for dep, email_type in self.EMAIL_INDICATORS.items():
if dep in all_deps:
email.append({"provider": email_type, "client": dep})
def _detect_payments(
self, all_deps: set[str], payments: list[dict[str, str]]
) -> None:
"""Detect payment processors."""
for dep, payment_type in self.PAYMENT_INDICATORS.items():
if dep in all_deps:
payments.append({"provider": payment_type, "client": dep})
def _detect_storage(
self, all_deps: set[str], storage: list[dict[str, str]]
) -> None:
"""Detect storage services."""
for dep, storage_type in self.STORAGE_INDICATORS.items():
if dep in all_deps:
storage.append({"provider": storage_type, "client": dep})
def _detect_auth_providers(
self, all_deps: set[str], auth: list[dict[str, str]]
) -> None:
"""Detect authentication providers."""
for dep, auth_type in self.AUTH_INDICATORS.items():
if dep in all_deps:
auth.append({"type": auth_type, "client": dep})
def _detect_monitoring(
self, all_deps: set[str], monitoring: list[dict[str, str]]
) -> None:
"""Detect monitoring and observability tools."""
for dep, monitoring_type in self.MONITORING_INDICATORS.items():
if dep in all_deps:
monitoring.append({"type": monitoring_type, "client": dep})
@@ -0,0 +1,102 @@
"""
Context Analyzer Module
=======================
Orchestrates comprehensive project context analysis including:
- Environment variables and configuration
- External service integrations
- Authentication patterns
- Database migrations
- Background jobs/task queues
- API documentation
- Monitoring and observability
This module delegates to specialized detectors for clean separation of concerns.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .base import BaseAnalyzer
from .context import (
ApiDocsDetector,
AuthDetector,
EnvironmentDetector,
JobsDetector,
MigrationsDetector,
MonitoringDetector,
ServicesDetector,
)
class ContextAnalyzer(BaseAnalyzer):
"""Orchestrates project context and configuration analysis."""
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect_environment_variables(self) -> None:
"""
Discover all environment variables from multiple sources.
Delegates to EnvironmentDetector for actual detection logic.
"""
detector = EnvironmentDetector(self.path, self.analysis)
detector.detect()
def detect_external_services(self) -> None:
"""
Detect external service integrations.
Delegates to ServicesDetector for actual detection logic.
"""
detector = ServicesDetector(self.path, self.analysis)
detector.detect()
def detect_auth_patterns(self) -> None:
"""
Detect authentication and authorization patterns.
Delegates to AuthDetector for actual detection logic.
"""
detector = AuthDetector(self.path, self.analysis)
detector.detect()
def detect_migrations(self) -> None:
"""
Detect database migration setup.
Delegates to MigrationsDetector for actual detection logic.
"""
detector = MigrationsDetector(self.path, self.analysis)
detector.detect()
def detect_background_jobs(self) -> None:
"""
Detect background job/task queue systems.
Delegates to JobsDetector for actual detection logic.
"""
detector = JobsDetector(self.path, self.analysis)
detector.detect()
def detect_api_documentation(self) -> None:
"""
Detect API documentation setup.
Delegates to ApiDocsDetector for actual detection logic.
"""
detector = ApiDocsDetector(self.path, self.analysis)
detector.detect()
def detect_monitoring(self) -> None:
"""
Detect monitoring and observability setup.
Delegates to MonitoringDetector for actual detection logic.
"""
detector = MonitoringDetector(self.path, self.analysis)
detector.detect()
@@ -0,0 +1,316 @@
"""
Database Detector Module
========================
Detects database models and schemas across different ORMs:
- Python: SQLAlchemy, Django ORM
- JavaScript/TypeScript: Prisma, TypeORM, Drizzle, Mongoose
"""
from __future__ import annotations
import re
from pathlib import Path
from .base import BaseAnalyzer
class DatabaseDetector(BaseAnalyzer):
"""Detects database models across multiple ORMs."""
def __init__(self, path: Path):
super().__init__(path)
def detect_all_models(self) -> dict:
"""Detect all database models across different ORMs."""
models = {}
# Python SQLAlchemy
models.update(self._detect_sqlalchemy_models())
# Python Django
models.update(self._detect_django_models())
# Prisma schema
models.update(self._detect_prisma_models())
# TypeORM entities
models.update(self._detect_typeorm_models())
# Drizzle schema
models.update(self._detect_drizzle_models())
# Mongoose models
models.update(self._detect_mongoose_models())
return models
def _detect_sqlalchemy_models(self) -> dict:
"""Detect SQLAlchemy models."""
models = {}
py_files = list(self.path.glob("**/*.py"))
for file_path in py_files:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Find class definitions that inherit from Base or db.Model
class_pattern = (
r"class\s+(\w+)\([^)]*(?:Base|db\.Model|DeclarativeBase)[^)]*\):"
)
matches = re.finditer(class_pattern, content)
for match in matches:
model_name = match.group(1)
# Extract table name if defined
table_match = re.search(r'__tablename__\s*=\s*["\'](\w+)["\']', content)
table_name = (
table_match.group(1) if table_match else model_name.lower() + "s"
)
# Extract columns
fields = {}
column_pattern = r"(\w+)\s*=\s*Column\((.*?)\)"
column_matches = re.finditer(
column_pattern, content[match.end() : match.end() + 2000]
)
for col_match in column_matches:
field_name = col_match.group(1)
field_def = col_match.group(2)
# Detect field properties
is_primary = "primary_key=True" in field_def
is_unique = "unique=True" in field_def
is_nullable = "nullable=False" not in field_def
# Extract type
type_match = re.search(
r"(Integer|String|Text|Boolean|DateTime|Float|JSON)", field_def
)
field_type = type_match.group(1) if type_match else "Unknown"
fields[field_name] = {
"type": field_type,
"primary_key": is_primary,
"unique": is_unique,
"nullable": is_nullable,
}
if fields: # Only add if we found fields
models[model_name] = {
"table": table_name,
"fields": fields,
"file": str(file_path.relative_to(self.path)),
"orm": "SQLAlchemy",
}
return models
def _detect_django_models(self) -> dict:
"""Detect Django models."""
models = {}
model_files = list(self.path.glob("**/models.py")) + list(
self.path.glob("**/models/*.py")
)
for file_path in model_files:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Find class definitions that inherit from models.Model
class_pattern = r"class\s+(\w+)\(models\.Model\):"
matches = re.finditer(class_pattern, content)
for match in matches:
model_name = match.group(1)
table_name = model_name.lower()
# Extract fields
fields = {}
field_pattern = r"(\w+)\s*=\s*models\.(\w+Field)\((.*?)\)"
field_matches = re.finditer(
field_pattern, content[match.end() : match.end() + 2000]
)
for field_match in field_matches:
field_name = field_match.group(1)
field_type = field_match.group(2)
field_args = field_match.group(3)
fields[field_name] = {
"type": field_type,
"unique": "unique=True" in field_args,
"nullable": "null=True" in field_args,
}
if fields:
models[model_name] = {
"table": table_name,
"fields": fields,
"file": str(file_path.relative_to(self.path)),
"orm": "Django",
}
return models
def _detect_prisma_models(self) -> dict:
"""Detect Prisma models from schema.prisma."""
models = {}
schema_file = self.path / "prisma" / "schema.prisma"
if not schema_file.exists():
return models
try:
content = schema_file.read_text()
except (OSError, UnicodeDecodeError):
return models
# Find model definitions
model_pattern = r"model\s+(\w+)\s*\{([^}]+)\}"
matches = re.finditer(model_pattern, content, re.MULTILINE)
for match in matches:
model_name = match.group(1)
model_body = match.group(2)
fields = {}
# Parse fields: id Int @id @default(autoincrement())
field_pattern = r"(\w+)\s+(\w+)([^/\n]*)"
field_matches = re.finditer(field_pattern, model_body)
for field_match in field_matches:
field_name = field_match.group(1)
field_type = field_match.group(2)
field_attrs = field_match.group(3)
fields[field_name] = {
"type": field_type,
"primary_key": "@id" in field_attrs,
"unique": "@unique" in field_attrs,
"nullable": "?" in field_type,
}
if fields:
models[model_name] = {
"table": model_name.lower(),
"fields": fields,
"file": "prisma/schema.prisma",
"orm": "Prisma",
}
return models
def _detect_typeorm_models(self) -> dict:
"""Detect TypeORM entities."""
models = {}
ts_files = list(self.path.glob("**/*.entity.ts")) + list(
self.path.glob("**/entities/*.ts")
)
for file_path in ts_files:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Find @Entity() class declarations
entity_pattern = r"@Entity\([^)]*\)\s*(?:export\s+)?class\s+(\w+)"
matches = re.finditer(entity_pattern, content)
for match in matches:
model_name = match.group(1)
# Extract columns
fields = {}
column_pattern = (
r"@(PrimaryGeneratedColumn|Column)\(([^)]*)\)\s+(\w+):\s*(\w+)"
)
column_matches = re.finditer(column_pattern, content)
for col_match in column_matches:
decorator = col_match.group(1)
options = col_match.group(2)
field_name = col_match.group(3)
field_type = col_match.group(4)
fields[field_name] = {
"type": field_type,
"primary_key": decorator == "PrimaryGeneratedColumn",
"unique": "unique: true" in options,
}
if fields:
models[model_name] = {
"table": model_name.lower(),
"fields": fields,
"file": str(file_path.relative_to(self.path)),
"orm": "TypeORM",
}
return models
def _detect_drizzle_models(self) -> dict:
"""Detect Drizzle ORM schemas."""
models = {}
schema_files = list(self.path.glob("**/schema.ts")) + list(
self.path.glob("**/db/schema.ts")
)
for file_path in schema_files:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Find table definitions: export const users = pgTable('users', {...})
table_pattern = r'export\s+const\s+(\w+)\s*=\s*(?:pg|mysql|sqlite)Table\(["\'](\w+)["\']'
matches = re.finditer(table_pattern, content)
for match in matches:
const_name = match.group(1)
table_name = match.group(2)
models[const_name] = {
"table": table_name,
"fields": {}, # Would need more parsing for fields
"file": str(file_path.relative_to(self.path)),
"orm": "Drizzle",
}
return models
def _detect_mongoose_models(self) -> dict:
"""Detect Mongoose models."""
models = {}
model_files = list(self.path.glob("**/models/*.js")) + list(
self.path.glob("**/models/*.ts")
)
for file_path in model_files:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Find mongoose.model() or new Schema()
model_pattern = r'mongoose\.model\(["\'](\w+)["\']'
matches = re.finditer(model_pattern, content)
for match in matches:
model_name = match.group(1)
models[model_name] = {
"table": model_name.lower(),
"fields": {},
"file": str(file_path.relative_to(self.path)),
"orm": "Mongoose",
}
return models
@@ -0,0 +1,301 @@
"""
Framework Analyzer Module
=========================
Detects programming languages, frameworks, and related technologies across different ecosystems.
Supports Python, Node.js/TypeScript, Go, Rust, and Ruby frameworks.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .base import BaseAnalyzer
class FrameworkAnalyzer(BaseAnalyzer):
"""Analyzes and detects programming languages and frameworks."""
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect_language_and_framework(self) -> None:
"""Detect primary language and framework."""
# Python detection
if self._exists("requirements.txt"):
self.analysis["language"] = "Python"
self.analysis["package_manager"] = "pip"
deps = self._read_file("requirements.txt")
self._detect_python_framework(deps)
elif self._exists("pyproject.toml"):
self.analysis["language"] = "Python"
content = self._read_file("pyproject.toml")
if "[tool.poetry]" in content:
self.analysis["package_manager"] = "poetry"
elif "[tool.uv]" in content:
self.analysis["package_manager"] = "uv"
else:
self.analysis["package_manager"] = "pip"
self._detect_python_framework(content)
elif self._exists("Pipfile"):
self.analysis["language"] = "Python"
self.analysis["package_manager"] = "pipenv"
content = self._read_file("Pipfile")
self._detect_python_framework(content)
# Node.js/TypeScript detection
elif self._exists("package.json"):
pkg = self._read_json("package.json")
if pkg:
# Check if TypeScript
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
if "typescript" in deps:
self.analysis["language"] = "TypeScript"
else:
self.analysis["language"] = "JavaScript"
self.analysis["package_manager"] = self._detect_node_package_manager()
self._detect_node_framework(pkg)
# Go detection
elif self._exists("go.mod"):
self.analysis["language"] = "Go"
self.analysis["package_manager"] = "go mod"
content = self._read_file("go.mod")
self._detect_go_framework(content)
# Rust detection
elif self._exists("Cargo.toml"):
self.analysis["language"] = "Rust"
self.analysis["package_manager"] = "cargo"
content = self._read_file("Cargo.toml")
self._detect_rust_framework(content)
# Ruby detection
elif self._exists("Gemfile"):
self.analysis["language"] = "Ruby"
self.analysis["package_manager"] = "bundler"
content = self._read_file("Gemfile")
self._detect_ruby_framework(content)
def _detect_python_framework(self, content: str) -> None:
"""Detect Python framework."""
from .port_detector import PortDetector
content_lower = content.lower()
# Web frameworks (with conventional defaults)
frameworks = {
"fastapi": {"name": "FastAPI", "type": "backend", "port": 8000},
"flask": {"name": "Flask", "type": "backend", "port": 5000},
"django": {"name": "Django", "type": "backend", "port": 8000},
"starlette": {"name": "Starlette", "type": "backend", "port": 8000},
"litestar": {"name": "Litestar", "type": "backend", "port": 8000},
}
for key, info in frameworks.items():
if key in content_lower:
self.analysis["framework"] = info["name"]
self.analysis["type"] = info["type"]
# Try to detect actual port, fall back to default
port_detector = PortDetector(self.path, self.analysis)
detected_port = port_detector.detect_port_from_sources(info["port"])
self.analysis["default_port"] = detected_port
break
# Task queues
if "celery" in content_lower:
self.analysis["task_queue"] = "Celery"
if not self.analysis.get("type"):
self.analysis["type"] = "worker"
elif "dramatiq" in content_lower:
self.analysis["task_queue"] = "Dramatiq"
elif "huey" in content_lower:
self.analysis["task_queue"] = "Huey"
# ORM
if "sqlalchemy" in content_lower:
self.analysis["orm"] = "SQLAlchemy"
elif "tortoise" in content_lower:
self.analysis["orm"] = "Tortoise ORM"
elif "prisma" in content_lower:
self.analysis["orm"] = "Prisma"
def _detect_node_framework(self, pkg: dict) -> None:
"""Detect Node.js/TypeScript framework."""
from .port_detector import PortDetector
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
deps_lower = {k.lower(): k for k in deps.keys()}
# Frontend frameworks
frontend_frameworks = {
"next": {"name": "Next.js", "type": "frontend", "port": 3000},
"nuxt": {"name": "Nuxt", "type": "frontend", "port": 3000},
"react": {"name": "React", "type": "frontend", "port": 3000},
"vue": {"name": "Vue", "type": "frontend", "port": 5173},
"svelte": {"name": "Svelte", "type": "frontend", "port": 5173},
"@sveltejs/kit": {"name": "SvelteKit", "type": "frontend", "port": 5173},
"angular": {"name": "Angular", "type": "frontend", "port": 4200},
"@angular/core": {"name": "Angular", "type": "frontend", "port": 4200},
"solid-js": {"name": "SolidJS", "type": "frontend", "port": 3000},
"astro": {"name": "Astro", "type": "frontend", "port": 4321},
}
# Backend frameworks
backend_frameworks = {
"express": {"name": "Express", "type": "backend", "port": 3000},
"fastify": {"name": "Fastify", "type": "backend", "port": 3000},
"koa": {"name": "Koa", "type": "backend", "port": 3000},
"hono": {"name": "Hono", "type": "backend", "port": 3000},
"elysia": {"name": "Elysia", "type": "backend", "port": 3000},
"@nestjs/core": {"name": "NestJS", "type": "backend", "port": 3000},
}
port_detector = PortDetector(self.path, self.analysis)
# Check frontend first (Next.js includes React, etc.)
for key, info in frontend_frameworks.items():
if key in deps_lower:
self.analysis["framework"] = info["name"]
self.analysis["type"] = info["type"]
detected_port = port_detector.detect_port_from_sources(info["port"])
self.analysis["default_port"] = detected_port
break
# If no frontend, check backend
if not self.analysis.get("framework"):
for key, info in backend_frameworks.items():
if key in deps_lower:
self.analysis["framework"] = info["name"]
self.analysis["type"] = info["type"]
detected_port = port_detector.detect_port_from_sources(info["port"])
self.analysis["default_port"] = detected_port
break
# Build tool
if "vite" in deps_lower:
self.analysis["build_tool"] = "Vite"
if not self.analysis.get("default_port"):
detected_port = port_detector.detect_port_from_sources(5173)
self.analysis["default_port"] = detected_port
elif "webpack" in deps_lower:
self.analysis["build_tool"] = "Webpack"
elif "esbuild" in deps_lower:
self.analysis["build_tool"] = "esbuild"
elif "turbopack" in deps_lower:
self.analysis["build_tool"] = "Turbopack"
# Styling
if "tailwindcss" in deps_lower:
self.analysis["styling"] = "Tailwind CSS"
elif "styled-components" in deps_lower:
self.analysis["styling"] = "styled-components"
elif "@emotion/react" in deps_lower:
self.analysis["styling"] = "Emotion"
# State management
if "zustand" in deps_lower:
self.analysis["state_management"] = "Zustand"
elif "@reduxjs/toolkit" in deps_lower or "redux" in deps_lower:
self.analysis["state_management"] = "Redux"
elif "jotai" in deps_lower:
self.analysis["state_management"] = "Jotai"
elif "pinia" in deps_lower:
self.analysis["state_management"] = "Pinia"
# Task queues
if "bullmq" in deps_lower or "bull" in deps_lower:
self.analysis["task_queue"] = "BullMQ"
if not self.analysis.get("type"):
self.analysis["type"] = "worker"
# ORM
if "@prisma/client" in deps_lower or "prisma" in deps_lower:
self.analysis["orm"] = "Prisma"
elif "typeorm" in deps_lower:
self.analysis["orm"] = "TypeORM"
elif "drizzle-orm" in deps_lower:
self.analysis["orm"] = "Drizzle"
elif "mongoose" in deps_lower:
self.analysis["orm"] = "Mongoose"
# Scripts
scripts = pkg.get("scripts", {})
if "dev" in scripts:
self.analysis["dev_command"] = "npm run dev"
elif "start" in scripts:
self.analysis["dev_command"] = "npm run start"
def _detect_go_framework(self, content: str) -> None:
"""Detect Go framework."""
from .port_detector import PortDetector
frameworks = {
"gin-gonic/gin": {"name": "Gin", "port": 8080},
"labstack/echo": {"name": "Echo", "port": 8080},
"gofiber/fiber": {"name": "Fiber", "port": 3000},
"go-chi/chi": {"name": "Chi", "port": 8080},
}
for key, info in frameworks.items():
if key in content:
self.analysis["framework"] = info["name"]
self.analysis["type"] = "backend"
port_detector = PortDetector(self.path, self.analysis)
detected_port = port_detector.detect_port_from_sources(info["port"])
self.analysis["default_port"] = detected_port
break
def _detect_rust_framework(self, content: str) -> None:
"""Detect Rust framework."""
from .port_detector import PortDetector
frameworks = {
"actix-web": {"name": "Actix Web", "port": 8080},
"axum": {"name": "Axum", "port": 3000},
"rocket": {"name": "Rocket", "port": 8000},
}
for key, info in frameworks.items():
if key in content:
self.analysis["framework"] = info["name"]
self.analysis["type"] = "backend"
port_detector = PortDetector(self.path, self.analysis)
detected_port = port_detector.detect_port_from_sources(info["port"])
self.analysis["default_port"] = detected_port
break
def _detect_ruby_framework(self, content: str) -> None:
"""Detect Ruby framework."""
from .port_detector import PortDetector
port_detector = PortDetector(self.path, self.analysis)
if "rails" in content.lower():
self.analysis["framework"] = "Ruby on Rails"
self.analysis["type"] = "backend"
detected_port = port_detector.detect_port_from_sources(3000)
self.analysis["default_port"] = detected_port
elif "sinatra" in content.lower():
self.analysis["framework"] = "Sinatra"
self.analysis["type"] = "backend"
detected_port = port_detector.detect_port_from_sources(4567)
self.analysis["default_port"] = detected_port
if "sidekiq" in content.lower():
self.analysis["task_queue"] = "Sidekiq"
def _detect_node_package_manager(self) -> str:
"""Detect Node.js package manager."""
if self._exists("pnpm-lock.yaml"):
return "pnpm"
elif self._exists("yarn.lock"):
return "yarn"
elif self._exists("bun.lockb"):
return "bun"
return "npm"
@@ -0,0 +1,337 @@
"""
Port Detector Module
====================
Detects application ports from multiple sources including entry points,
environment files, Docker Compose, configuration files, and scripts.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from .base import BaseAnalyzer
class PortDetector(BaseAnalyzer):
"""Detects application ports from various configuration sources."""
def __init__(self, path: Path, analysis: dict[str, Any]):
super().__init__(path)
self.analysis = analysis
def detect_port_from_sources(self, default_port: int) -> int:
"""
Robustly detect the actual port by checking multiple sources.
Checks in order of priority:
1. Entry point files (app.py, main.py, etc.) for uvicorn.run(), app.run(), etc.
2. Environment files (.env, .env.local, .env.development)
3. Docker Compose port mappings
4. Configuration files (config.py, settings.py, etc.)
5. Package.json scripts (for Node.js)
6. Makefile/shell scripts
7. Falls back to default_port if nothing found
Args:
default_port: The framework's conventional default port
Returns:
Detected port or default_port if not found
"""
# 1. Check entry point files for explicit port definitions
port = self._detect_port_in_entry_points()
if port:
return port
# 2. Check environment files
port = self._detect_port_in_env_files()
if port:
return port
# 3. Check Docker Compose
port = self._detect_port_in_docker_compose()
if port:
return port
# 4. Check configuration files
port = self._detect_port_in_config_files()
if port:
return port
# 5. Check package.json scripts (for Node.js)
if self.analysis.get("language") in ["JavaScript", "TypeScript"]:
port = self._detect_port_in_package_scripts()
if port:
return port
# 6. Check Makefile/shell scripts
port = self._detect_port_in_scripts()
if port:
return port
# Fall back to default
return default_port
def _detect_port_in_entry_points(self) -> int | None:
"""Detect port in entry point files."""
entry_files = [
"app.py",
"main.py",
"server.py",
"__main__.py",
"asgi.py",
"wsgi.py",
"src/app.py",
"src/main.py",
"src/server.py",
"index.js",
"index.ts",
"server.js",
"server.ts",
"main.js",
"main.ts",
"src/index.js",
"src/index.ts",
"src/server.js",
"src/server.ts",
"main.go",
"cmd/main.go",
"src/main.rs",
]
# Patterns to search for ports
patterns = [
# Python: uvicorn.run(app, host="0.0.0.0", port=8050)
r"uvicorn\.run\([^)]*port\s*=\s*(\d+)",
# Python: app.run(port=8050, host="0.0.0.0")
r"\.run\([^)]*port\s*=\s*(\d+)",
# Python: port = 8050 or PORT = 8050
r"^\s*[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
# Python: os.getenv("PORT", 8050) or os.environ.get("PORT", 8050)
r'getenv\(\s*["\']PORT["\']\s*,\s*(\d+)',
r'environ\.get\(\s*["\']PORT["\']\s*,\s*(\d+)',
# JavaScript/TypeScript: app.listen(8050)
r"\.listen\(\s*(\d+)",
# JavaScript/TypeScript: const PORT = 8050 or let port = 8050
r"(?:const|let|var)\s+[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
# JavaScript/TypeScript: process.env.PORT || 8050
r"process\.env\.PORT\s*\|\|\s*(\d+)",
# JavaScript/TypeScript: Number(process.env.PORT) || 8050
r"Number\(process\.env\.PORT\)\s*\|\|\s*(\d+)",
# Go: :8050 or ":8050"
r':\s*(\d+)(?:["\s]|$)',
# Rust: .bind("127.0.0.1:8050")
r'\.bind\(["\'][\d.]+:(\d+)',
]
for entry_file in entry_files:
content = self._read_file(entry_file)
if not content:
continue
for pattern in patterns:
matches = re.findall(pattern, content, re.MULTILINE)
if matches:
# Return the first valid port found
for match in matches:
try:
port = int(match)
if 1000 <= port <= 65535: # Valid port range
return port
except ValueError:
continue
return None
def _detect_port_in_env_files(self) -> int | None:
"""Detect port in environment files."""
env_files = [
".env",
".env.local",
".env.development",
".env.dev",
"config/.env",
"config/.env.local",
"../.env",
]
patterns = [
r"^\s*PORT\s*=\s*(\d+)",
r"^\s*API_PORT\s*=\s*(\d+)",
r"^\s*SERVER_PORT\s*=\s*(\d+)",
r"^\s*APP_PORT\s*=\s*(\d+)",
]
for env_file in env_files:
content = self._read_file(env_file)
if not content:
continue
for pattern in patterns:
matches = re.findall(pattern, content, re.MULTILINE)
if matches:
try:
port = int(matches[0])
if 1000 <= port <= 65535:
return port
except ValueError:
continue
return None
def _detect_port_in_docker_compose(self) -> int | None:
"""Detect port from docker-compose.yml mappings."""
compose_files = [
"docker-compose.yml",
"docker-compose.yaml",
"../docker-compose.yml",
"../docker-compose.yaml",
]
service_name = self.path.name.lower()
for compose_file in compose_files:
content = self._read_file(compose_file)
if not content:
continue
# Look for port mappings like "8050:8000" or "8050:8050"
# Match the service name if possible
pattern = r'^\s*-\s*["\']?(\d+):\d+["\']?'
in_service = False
in_ports = False
for line in content.split("\n"):
# Check if we're in the right service block
if re.match(rf"^\s*{re.escape(service_name)}\s*:", line):
in_service = True
continue
# Check if we hit another service
if (
in_service
and re.match(r"^\s*\w+\s*:", line)
and "ports:" not in line
):
in_service = False
in_ports = False
continue
# Check if we're in the ports section
if in_service and "ports:" in line:
in_ports = True
continue
# Extract port mapping
if in_ports:
match = re.match(pattern, line)
if match:
try:
port = int(match.group(1))
if 1000 <= port <= 65535:
return port
except ValueError:
continue
return None
def _detect_port_in_config_files(self) -> int | None:
"""Detect port in configuration files."""
config_files = [
"config.py",
"settings.py",
"config/settings.py",
"src/config.py",
"config.json",
"settings.json",
"config/config.json",
"config.toml",
"settings.toml",
]
for config_file in config_files:
content = self._read_file(config_file)
if not content:
continue
# Python config patterns
patterns = [
r"[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
r'["\']port["\']\s*:\s*(\d+)',
]
for pattern in patterns:
matches = re.findall(pattern, content)
if matches:
try:
port = int(matches[0])
if 1000 <= port <= 65535:
return port
except ValueError:
continue
return None
def _detect_port_in_package_scripts(self) -> int | None:
"""Detect port in package.json scripts."""
pkg = self._read_json("package.json")
if not pkg:
return None
scripts = pkg.get("scripts", {})
# Look for port specifications in scripts
# e.g., "dev": "next dev -p 3001"
# e.g., "start": "node server.js --port 8050"
patterns = [
r"-p\s+(\d+)",
r"--port\s+(\d+)",
r"PORT=(\d+)",
]
for script in scripts.values():
if not isinstance(script, str):
continue
for pattern in patterns:
matches = re.findall(pattern, script)
if matches:
try:
port = int(matches[0])
if 1000 <= port <= 65535:
return port
except ValueError:
continue
return None
def _detect_port_in_scripts(self) -> int | None:
"""Detect port in Makefile or shell scripts."""
script_files = ["Makefile", "start.sh", "run.sh", "dev.sh"]
patterns = [
r"PORT=(\d+)",
r"--port\s+(\d+)",
r"-p\s+(\d+)",
]
for script_file in script_files:
content = self._read_file(script_file)
if not content:
continue
for pattern in patterns:
matches = re.findall(pattern, content)
if matches:
try:
port = int(matches[0])
if 1000 <= port <= 65535:
return port
except ValueError:
continue
return None
@@ -0,0 +1,292 @@
"""
Project Analyzer Module
=======================
Analyzes entire projects, detecting monorepo structures, services, infrastructure, and conventions.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .base import SERVICE_INDICATORS, SERVICE_ROOT_FILES, SKIP_DIRS
from .service_analyzer import ServiceAnalyzer
class ProjectAnalyzer:
"""Analyzes an entire project, detecting monorepo structure and all services."""
def __init__(self, project_dir: Path):
self.project_dir = project_dir.resolve()
self.index = {
"project_root": str(self.project_dir),
"project_type": "single", # or "monorepo"
"services": {},
"infrastructure": {},
"conventions": {},
}
def analyze(self) -> dict[str, Any]:
"""Run full project analysis."""
self._detect_project_type()
self._find_and_analyze_services()
self._analyze_infrastructure()
self._detect_conventions()
self._map_dependencies()
return self.index
def _detect_project_type(self) -> None:
"""Detect if this is a monorepo or single project."""
monorepo_indicators = [
"pnpm-workspace.yaml",
"lerna.json",
"nx.json",
"turbo.json",
"rush.json",
]
for indicator in monorepo_indicators:
if (self.project_dir / indicator).exists():
self.index["project_type"] = "monorepo"
self.index["monorepo_tool"] = indicator.replace(".json", "").replace(
".yaml", ""
)
return
# Check for packages/apps directories
if (self.project_dir / "packages").exists() or (
self.project_dir / "apps"
).exists():
self.index["project_type"] = "monorepo"
return
# Check for multiple service directories
service_dirs_found = 0
for item in self.project_dir.iterdir():
if not item.is_dir():
continue
if item.name in SKIP_DIRS or item.name.startswith("."):
continue
# Check if this directory has service root files
if any((item / f).exists() for f in SERVICE_ROOT_FILES):
service_dirs_found += 1
# If we have 2+ directories with service root files, it's likely a monorepo
if service_dirs_found >= 2:
self.index["project_type"] = "monorepo"
def _find_and_analyze_services(self) -> None:
"""Find all services and analyze each."""
services = {}
if self.index["project_type"] == "monorepo":
# Look for services in common locations
service_locations = [
self.project_dir,
self.project_dir / "packages",
self.project_dir / "apps",
self.project_dir / "services",
]
for location in service_locations:
if not location.exists():
continue
for item in location.iterdir():
if not item.is_dir():
continue
if item.name in SKIP_DIRS:
continue
if item.name.startswith("."):
continue
# Check if this looks like a service
has_root_file = any((item / f).exists() for f in SERVICE_ROOT_FILES)
is_service_name = item.name.lower() in SERVICE_INDICATORS
if has_root_file or (
location == self.project_dir and is_service_name
):
analyzer = ServiceAnalyzer(item, item.name)
service_info = analyzer.analyze()
if service_info.get(
"language"
): # Only include if we detected something
services[item.name] = service_info
else:
# Single project - analyze root
analyzer = ServiceAnalyzer(self.project_dir, "main")
service_info = analyzer.analyze()
if service_info.get("language"):
services["main"] = service_info
self.index["services"] = services
def _analyze_infrastructure(self) -> None:
"""Analyze infrastructure configuration."""
infra = {}
# Docker
if (self.project_dir / "docker-compose.yml").exists():
infra["docker_compose"] = "docker-compose.yml"
compose_content = self._read_file("docker-compose.yml")
infra["docker_services"] = self._parse_compose_services(compose_content)
elif (self.project_dir / "docker-compose.yaml").exists():
infra["docker_compose"] = "docker-compose.yaml"
compose_content = self._read_file("docker-compose.yaml")
infra["docker_services"] = self._parse_compose_services(compose_content)
if (self.project_dir / "Dockerfile").exists():
infra["dockerfile"] = "Dockerfile"
# Docker directory
docker_dir = self.project_dir / "docker"
if docker_dir.exists():
dockerfiles = list(docker_dir.glob("Dockerfile*")) + list(
docker_dir.glob("*.Dockerfile")
)
if dockerfiles:
infra["docker_directory"] = "docker/"
infra["dockerfiles"] = [
str(f.relative_to(self.project_dir)) for f in dockerfiles
]
# CI/CD
if (self.project_dir / ".github" / "workflows").exists():
infra["ci"] = "GitHub Actions"
workflows = list((self.project_dir / ".github" / "workflows").glob("*.yml"))
infra["ci_workflows"] = [f.name for f in workflows]
elif (self.project_dir / ".gitlab-ci.yml").exists():
infra["ci"] = "GitLab CI"
elif (self.project_dir / ".circleci").exists():
infra["ci"] = "CircleCI"
# Deployment
deployment_files = {
"vercel.json": "Vercel",
"netlify.toml": "Netlify",
"fly.toml": "Fly.io",
"render.yaml": "Render",
"railway.json": "Railway",
"Procfile": "Heroku",
"app.yaml": "Google App Engine",
"serverless.yml": "Serverless Framework",
}
for file, platform in deployment_files.items():
if (self.project_dir / file).exists():
infra["deployment"] = platform
break
self.index["infrastructure"] = infra
def _parse_compose_services(self, content: str) -> list[str]:
"""Extract service names from docker-compose content."""
services = []
in_services = False
for line in content.split("\n"):
if line.strip() == "services:":
in_services = True
continue
if in_services:
# Service names are at 2-space indent
if (
line.startswith(" ")
and not line.startswith(" ")
and line.strip().endswith(":")
):
service_name = line.strip().rstrip(":")
services.append(service_name)
elif line and not line.startswith(" "):
break # End of services section
return services
def _detect_conventions(self) -> None:
"""Detect project-wide conventions."""
conventions = {}
# Python linting
if (self.project_dir / "ruff.toml").exists() or self._has_in_pyproject("ruff"):
conventions["python_linting"] = "Ruff"
elif (self.project_dir / ".flake8").exists():
conventions["python_linting"] = "Flake8"
elif (self.project_dir / "pylintrc").exists():
conventions["python_linting"] = "Pylint"
# Python formatting
if (self.project_dir / "pyproject.toml").exists():
content = self._read_file("pyproject.toml")
if "[tool.black]" in content:
conventions["python_formatting"] = "Black"
# JavaScript/TypeScript linting
eslint_files = [
".eslintrc",
".eslintrc.js",
".eslintrc.json",
".eslintrc.yml",
"eslint.config.js",
]
if any((self.project_dir / f).exists() for f in eslint_files):
conventions["js_linting"] = "ESLint"
# Prettier
prettier_files = [
".prettierrc",
".prettierrc.js",
".prettierrc.json",
"prettier.config.js",
]
if any((self.project_dir / f).exists() for f in prettier_files):
conventions["formatting"] = "Prettier"
# TypeScript
if (self.project_dir / "tsconfig.json").exists():
conventions["typescript"] = True
# Git hooks
if (self.project_dir / ".husky").exists():
conventions["git_hooks"] = "Husky"
elif (self.project_dir / ".pre-commit-config.yaml").exists():
conventions["git_hooks"] = "pre-commit"
self.index["conventions"] = conventions
def _map_dependencies(self) -> None:
"""Map dependencies between services."""
services = self.index.get("services", {})
for service_name, service_info in services.items():
consumes = []
# Check for API client patterns
if service_info.get("type") == "frontend":
# Frontend typically consumes backend
for other_name, other_info in services.items():
if other_info.get("type") == "backend":
consumes.append(f"{other_name}.api")
# Check for shared libraries
if service_info.get("dependencies"):
deps = service_info["dependencies"]
for other_name in services.keys():
if other_name in deps or f"@{other_name}" in str(deps):
consumes.append(other_name)
if consumes:
service_info["consumes"] = consumes
def _has_in_pyproject(self, tool: str) -> bool:
"""Check if a tool is configured in pyproject.toml."""
if (self.project_dir / "pyproject.toml").exists():
content = self._read_file("pyproject.toml")
return f"[tool.{tool}]" in content
return False
def _read_file(self, path: str) -> str:
try:
return (self.project_dir / path).read_text()
except (OSError, UnicodeDecodeError):
return ""
@@ -0,0 +1,418 @@
"""
Route Detector Module
=====================
Detects API routes and endpoints across different frameworks:
- Python: FastAPI, Flask, Django
- Node.js: Express, Next.js
- Go: Gin, Echo, Chi, Fiber
- Rust: Axum, Actix
"""
from __future__ import annotations
import re
from pathlib import Path
from .base import BaseAnalyzer
class RouteDetector(BaseAnalyzer):
"""Detects API routes across multiple web frameworks."""
# Directories to exclude from route detection
EXCLUDED_DIRS = {"node_modules", ".venv", "venv", "__pycache__", ".git"}
def __init__(self, path: Path):
super().__init__(path)
def _should_include_file(self, file_path: Path) -> bool:
"""Check if file should be included (not in excluded directories)."""
return not any(part in self.EXCLUDED_DIRS for part in file_path.parts)
def detect_all_routes(self) -> list[dict]:
"""Detect all API routes across different frameworks."""
routes = []
# Python FastAPI
routes.extend(self._detect_fastapi_routes())
# Python Flask
routes.extend(self._detect_flask_routes())
# Python Django
routes.extend(self._detect_django_routes())
# Node.js Express/Fastify/Koa
routes.extend(self._detect_express_routes())
# Next.js (file-based routing)
routes.extend(self._detect_nextjs_routes())
# Go Gin/Echo/Chi
routes.extend(self._detect_go_routes())
# Rust Axum/Actix
routes.extend(self._detect_rust_routes())
return routes
def _detect_fastapi_routes(self) -> list[dict]:
"""Detect FastAPI routes."""
routes = []
files_to_check = [
f for f in self.path.glob("**/*.py") if self._should_include_file(f)
]
for file_path in files_to_check:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Pattern: @app.get("/path") or @router.post("/path", dependencies=[...])
patterns = [
(
r'@(?:app|router)\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']',
"decorator",
),
(
r'@(?:app|router)\.api_route\(["\']([^"\']+)["\'][^)]*methods\s*=\s*\[([^\]]+)\]',
"api_route",
),
]
for pattern, pattern_type in patterns:
matches = re.finditer(pattern, content, re.MULTILINE)
for match in matches:
if pattern_type == "decorator":
method = match.group(1).upper()
path = match.group(2)
methods = [method]
else:
path = match.group(1)
methods_str = match.group(2)
methods = [
m.strip().strip('"').strip("'").upper()
for m in methods_str.split(",")
]
# Check if route requires auth (has Depends in the decorator)
line_start = content.rfind("\n", 0, match.start()) + 1
line_end = content.find("\n", match.end())
route_definition = content[
line_start : line_end if line_end != -1 else len(content)
]
requires_auth = (
"Depends" in route_definition
or "require" in route_definition.lower()
)
routes.append(
{
"path": path,
"methods": methods,
"file": str(file_path.relative_to(self.path)),
"framework": "FastAPI",
"requires_auth": requires_auth,
}
)
return routes
def _detect_flask_routes(self) -> list[dict]:
"""Detect Flask routes."""
routes = []
files_to_check = [
f for f in self.path.glob("**/*.py") if self._should_include_file(f)
]
for file_path in files_to_check:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Pattern: @app.route("/path", methods=["GET", "POST"])
pattern = r'@(?:app|bp|blueprint)\.route\(["\']([^"\']+)["\'](?:[^)]*methods\s*=\s*\[([^\]]+)\])?'
matches = re.finditer(pattern, content, re.MULTILINE)
for match in matches:
path = match.group(1)
methods_str = match.group(2)
if methods_str:
methods = [
m.strip().strip('"').strip("'").upper()
for m in methods_str.split(",")
]
else:
methods = ["GET"] # Flask default
# Check for @login_required decorator
decorator_start = content.rfind("@", 0, match.start())
decorator_section = content[decorator_start : match.end()]
requires_auth = (
"login_required" in decorator_section
or "require" in decorator_section.lower()
)
routes.append(
{
"path": path,
"methods": methods,
"file": str(file_path.relative_to(self.path)),
"framework": "Flask",
"requires_auth": requires_auth,
}
)
return routes
def _detect_django_routes(self) -> list[dict]:
"""Detect Django routes from urls.py files."""
routes = []
url_files = [
f for f in self.path.glob("**/urls.py") if self._should_include_file(f)
]
for file_path in url_files:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Pattern: path('users/<int:id>/', views.user_detail)
patterns = [
r'path\(["\']([^"\']+)["\']',
r're_path\([r]?["\']([^"\']+)["\']',
]
for pattern in patterns:
matches = re.finditer(pattern, content)
for match in matches:
path = match.group(1)
routes.append(
{
"path": f"/{path}" if not path.startswith("/") else path,
"methods": ["GET", "POST"], # Django allows both by default
"file": str(file_path.relative_to(self.path)),
"framework": "Django",
"requires_auth": False, # Can't easily detect without middleware analysis
}
)
return routes
def _detect_express_routes(self) -> list[dict]:
"""Detect Express/Fastify/Koa routes."""
routes = []
js_files = [
f for f in self.path.glob("**/*.js") if self._should_include_file(f)
]
ts_files = [
f for f in self.path.glob("**/*.ts") if self._should_include_file(f)
]
files_to_check = js_files + ts_files
for file_path in files_to_check:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Pattern: app.get('/path', handler) or router.post('/path', middleware, handler)
pattern = (
r'(?:app|router)\.(get|post|put|delete|patch|use)\(["\']([^"\']+)["\']'
)
matches = re.finditer(pattern, content)
for match in matches:
method = match.group(1).upper()
path = match.group(2)
if method == "USE":
# .use() is middleware, might be a route prefix
continue
# Check for auth middleware in the route definition
line_start = content.rfind("\n", 0, match.start()) + 1
line_end = content.find("\n", match.end())
route_line = content[
line_start : line_end if line_end != -1 else len(content)
]
requires_auth = any(
keyword in route_line.lower()
for keyword in ["auth", "authenticate", "protect", "require"]
)
routes.append(
{
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Express",
"requires_auth": requires_auth,
}
)
return routes
def _detect_nextjs_routes(self) -> list[dict]:
"""Detect Next.js file-based routes."""
routes = []
# Next.js App Router (app directory)
app_dir = self.path / "app"
if app_dir.exists():
# Find all route.ts/js files
route_files = [
f
for f in app_dir.glob("**/route.{ts,js,tsx,jsx}")
if self._should_include_file(f)
]
for route_file in route_files:
# Convert file path to route path
# app/api/users/[id]/route.ts -> /api/users/:id
relative_path = route_file.parent.relative_to(app_dir)
route_path = "/" + str(relative_path).replace("\\", "/")
# Convert [id] to :id
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
try:
content = route_file.read_text()
# Detect exported methods: export async function GET(request)
methods = re.findall(
r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)",
content,
)
if methods:
routes.append(
{
"path": route_path,
"methods": methods,
"file": str(route_file.relative_to(self.path)),
"framework": "Next.js",
"requires_auth": "auth" in content.lower(),
}
)
except (OSError, UnicodeDecodeError):
continue
# Next.js Pages Router (pages/api directory)
pages_api = self.path / "pages" / "api"
if pages_api.exists():
api_files = [
f
for f in pages_api.glob("**/*.{ts,js,tsx,jsx}")
if self._should_include_file(f)
]
for api_file in api_files:
if api_file.name.startswith("_"):
continue
# Convert file path to route
relative_path = api_file.relative_to(pages_api)
route_path = "/api/" + str(relative_path.with_suffix("")).replace(
"\\", "/"
)
# Convert [id] to :id
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
routes.append(
{
"path": route_path,
"methods": [
"GET",
"POST",
], # Next.js API routes handle all methods
"file": str(api_file.relative_to(self.path)),
"framework": "Next.js",
"requires_auth": False,
}
)
return routes
def _detect_go_routes(self) -> list[dict]:
"""Detect Go framework routes (Gin, Echo, Chi, Fiber)."""
routes = []
go_files = [
f for f in self.path.glob("**/*.go") if self._should_include_file(f)
]
for file_path in go_files:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Gin: r.GET("/path", handler)
# Echo: e.POST("/path", handler)
# Chi: r.Get("/path", handler)
# Fiber: app.Get("/path", handler)
pattern = r'(?:r|e|app|router)\.(GET|POST|PUT|DELETE|PATCH|Get|Post|Put|Delete|Patch)\(["\']([^"\']+)["\']'
matches = re.finditer(pattern, content)
for match in matches:
method = match.group(1).upper()
path = match.group(2)
routes.append(
{
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Go",
"requires_auth": False,
}
)
return routes
def _detect_rust_routes(self) -> list[dict]:
"""Detect Rust framework routes (Axum, Actix)."""
routes = []
rust_files = [
f for f in self.path.glob("**/*.rs") if self._should_include_file(f)
]
for file_path in rust_files:
try:
content = file_path.read_text()
except (OSError, UnicodeDecodeError):
continue
# Axum: .route("/path", get(handler))
# Actix: web::get().to(handler)
patterns = [
r'\.route\(["\']([^"\']+)["\'],\s*(get|post|put|delete|patch)',
r"web::(get|post|put|delete|patch)\(\)",
]
for pattern in patterns:
matches = re.finditer(pattern, content)
for match in matches:
if len(match.groups()) == 2:
path = match.group(1)
method = match.group(2).upper()
else:
path = "/" # Can't determine path from web:: syntax
method = match.group(1).upper()
routes.append(
{
"path": path,
"methods": [method],
"file": str(file_path.relative_to(self.path)),
"framework": "Rust",
"requires_auth": False,
}
)
return routes
@@ -0,0 +1,313 @@
"""
Service Analyzer Module
=======================
Main ServiceAnalyzer class that coordinates all analysis for a single service/package.
Integrates framework detection, route analysis, database models, and context extraction.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from .base import BaseAnalyzer
from .context_analyzer import ContextAnalyzer
from .database_detector import DatabaseDetector
from .framework_analyzer import FrameworkAnalyzer
from .route_detector import RouteDetector
class ServiceAnalyzer(BaseAnalyzer):
"""Analyzes a single service/package within a project."""
def __init__(self, service_path: Path, service_name: str):
super().__init__(service_path)
self.name = service_name
self.analysis = {
"name": service_name,
"path": str(service_path),
"language": None,
"framework": None,
"type": None, # backend, frontend, worker, library, etc.
}
def analyze(self) -> dict[str, Any]:
"""Run full analysis on this service."""
self._detect_language_and_framework()
self._detect_service_type()
self._find_key_directories()
self._find_entry_points()
self._detect_dependencies()
self._detect_testing()
self._find_dockerfile()
# Comprehensive context extraction
self._detect_environment_variables()
self._detect_api_routes()
self._detect_database_models()
self._detect_external_services()
self._detect_auth_patterns()
self._detect_migrations()
self._detect_background_jobs()
self._detect_api_documentation()
self._detect_monitoring()
return self.analysis
def _detect_language_and_framework(self) -> None:
"""Detect primary language and framework."""
framework_analyzer = FrameworkAnalyzer(self.path, self.analysis)
framework_analyzer.detect_language_and_framework()
def _detect_service_type(self) -> None:
"""Infer service type from name and content if not already set."""
if self.analysis.get("type"):
return
name_lower = self.name.lower()
# Infer from name
if any(kw in name_lower for kw in ["frontend", "client", "web", "ui", "app"]):
self.analysis["type"] = "frontend"
elif any(kw in name_lower for kw in ["backend", "api", "server", "service"]):
self.analysis["type"] = "backend"
elif any(
kw in name_lower for kw in ["worker", "job", "queue", "task", "celery"]
):
self.analysis["type"] = "worker"
elif any(kw in name_lower for kw in ["scraper", "crawler", "spider"]):
self.analysis["type"] = "scraper"
elif any(kw in name_lower for kw in ["proxy", "gateway", "router"]):
self.analysis["type"] = "proxy"
elif any(
kw in name_lower for kw in ["lib", "shared", "common", "core", "utils"]
):
self.analysis["type"] = "library"
else:
# Try to infer from language and content if name doesn't match
language = self.analysis.get("language")
if language == "Python":
# Check if it's a CLI tool, framework, or backend service
has_run_py = (self.path / "run.py").exists()
has_main_py = (self.path / "main.py").exists()
has_main_module = (self.path / "__main__.py").exists()
# Check for agent/automation framework patterns
has_agent_files = any(
(self.path / f).exists()
for f in ["agent.py", "agents", "runner.py", "runners"]
)
if has_run_py or has_main_py or has_main_module or has_agent_files:
# It's a backend tool/framework/CLI
self.analysis["type"] = "backend"
return
# Default to unknown if no clear indicators
self.analysis["type"] = "unknown"
def _find_key_directories(self) -> None:
"""Find important directories within this service."""
key_dirs = {}
# Common directory patterns
patterns = {
"src": "Source code",
"lib": "Library code",
"app": "Application code",
"api": "API endpoints",
"routes": "Route handlers",
"controllers": "Controllers",
"models": "Data models",
"schemas": "Schemas/DTOs",
"services": "Business logic",
"components": "UI components",
"pages": "Page components",
"views": "Views/templates",
"hooks": "Custom hooks",
"utils": "Utilities",
"helpers": "Helper functions",
"middleware": "Middleware",
"tests": "Tests",
"test": "Tests",
"__tests__": "Tests",
"config": "Configuration",
"tasks": "Background tasks",
"jobs": "Background jobs",
"workers": "Worker processes",
}
for dir_name, purpose in patterns.items():
dir_path = self.path / dir_name
if dir_path.exists() and dir_path.is_dir():
key_dirs[dir_name] = {
"path": str(dir_path.relative_to(self.path)),
"purpose": purpose,
}
if key_dirs:
self.analysis["key_directories"] = key_dirs
def _find_entry_points(self) -> None:
"""Find main entry point files."""
entry_patterns = [
"main.py",
"app.py",
"__main__.py",
"server.py",
"wsgi.py",
"asgi.py",
"index.ts",
"index.js",
"main.ts",
"main.js",
"server.ts",
"server.js",
"app.ts",
"app.js",
"src/index.ts",
"src/index.js",
"src/main.ts",
"src/app.ts",
"src/server.ts",
"src/App.tsx",
"src/App.jsx",
"pages/_app.tsx",
"pages/_app.js", # Next.js
"main.go",
"cmd/main.go",
"src/main.rs",
"src/lib.rs",
]
for pattern in entry_patterns:
if self._exists(pattern):
self.analysis["entry_point"] = pattern
break
def _detect_dependencies(self) -> None:
"""Extract key dependencies."""
if self._exists("package.json"):
pkg = self._read_json("package.json")
if pkg:
deps = pkg.get("dependencies", {})
dev_deps = pkg.get("devDependencies", {})
self.analysis["dependencies"] = list(deps.keys())[:20] # Top 20
self.analysis["dev_dependencies"] = list(dev_deps.keys())[:10]
elif self._exists("requirements.txt"):
content = self._read_file("requirements.txt")
deps = []
for line in content.split("\n"):
line = line.strip()
if line and not line.startswith("#") and not line.startswith("-"):
match = re.match(r"^([a-zA-Z0-9_-]+)", line)
if match:
deps.append(match.group(1))
self.analysis["dependencies"] = deps[:20]
def _detect_testing(self) -> None:
"""Detect testing framework and configuration."""
if self._exists("package.json"):
pkg = self._read_json("package.json")
if pkg:
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
if "vitest" in deps:
self.analysis["testing"] = "Vitest"
elif "jest" in deps:
self.analysis["testing"] = "Jest"
if "@playwright/test" in deps:
self.analysis["e2e_testing"] = "Playwright"
elif "cypress" in deps:
self.analysis["e2e_testing"] = "Cypress"
elif self._exists("pytest.ini") or self._exists("pyproject.toml"):
self.analysis["testing"] = "pytest"
# Find test directory
for test_dir in ["tests", "test", "__tests__", "spec"]:
if self._exists(test_dir):
self.analysis["test_directory"] = test_dir
break
def _find_dockerfile(self) -> None:
"""Find Dockerfile for this service."""
dockerfile_patterns = [
"Dockerfile",
f"Dockerfile.{self.name}",
f"docker/{self.name}.Dockerfile",
f"docker/Dockerfile.{self.name}",
"../docker/Dockerfile." + self.name,
]
for pattern in dockerfile_patterns:
if self._exists(pattern):
self.analysis["dockerfile"] = pattern
break
def _detect_environment_variables(self) -> None:
"""Detect environment variables."""
context = ContextAnalyzer(self.path, self.analysis)
context.detect_environment_variables()
def _detect_api_routes(self) -> None:
"""Detect API routes."""
route_detector = RouteDetector(self.path)
routes = route_detector.detect_all_routes()
if routes:
self.analysis["api"] = {
"routes": routes,
"total_routes": len(routes),
"methods": list(
set(method for r in routes for method in r.get("methods", []))
),
"protected_routes": [
r["path"] for r in routes if r.get("requires_auth")
],
}
def _detect_database_models(self) -> None:
"""Detect database models."""
db_detector = DatabaseDetector(self.path)
models = db_detector.detect_all_models()
if models:
self.analysis["database"] = {
"models": models,
"total_models": len(models),
"model_names": list(models.keys()),
}
def _detect_external_services(self) -> None:
"""Detect external services."""
context = ContextAnalyzer(self.path, self.analysis)
context.detect_external_services()
def _detect_auth_patterns(self) -> None:
"""Detect authentication patterns."""
context = ContextAnalyzer(self.path, self.analysis)
context.detect_auth_patterns()
def _detect_migrations(self) -> None:
"""Detect database migrations."""
context = ContextAnalyzer(self.path, self.analysis)
context.detect_migrations()
def _detect_background_jobs(self) -> None:
"""Detect background jobs."""
context = ContextAnalyzer(self.path, self.analysis)
context.detect_background_jobs()
def _detect_api_documentation(self) -> None:
"""Detect API documentation."""
context = ContextAnalyzer(self.path, self.analysis)
context.detect_api_documentation()
def _detect_monitoring(self) -> None:
"""Detect monitoring setup."""
context = ContextAnalyzer(self.path, self.analysis)
context.detect_monitoring()
+589
View File
@@ -0,0 +1,589 @@
#!/usr/bin/env python3
"""
CI Discovery Module
===================
Parses CI/CD configuration files to extract test commands and workflows.
Supports GitHub Actions, GitLab CI, CircleCI, and Jenkins.
The CI discovery results are used by:
- QA Agent: To understand existing CI test patterns
- Validation Strategy: To match CI commands
- Planner: To align verification with CI
Usage:
from ci_discovery import CIDiscovery
discovery = CIDiscovery()
result = discovery.discover(project_dir)
if result:
print(f"CI System: {result.ci_system}")
print(f"Test Commands: {result.test_commands}")
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Try to import yaml, fall back gracefully
try:
import yaml
HAS_YAML = True
except ImportError:
HAS_YAML = False
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class CIWorkflow:
"""
Represents a CI workflow or job.
Attributes:
name: Name of the workflow/job
trigger: What triggers this workflow (push, pull_request, etc.)
steps: List of step names or commands
test_related: Whether this appears to be test-related
"""
name: str
trigger: list[str] = field(default_factory=list)
steps: list[str] = field(default_factory=list)
test_related: bool = False
@dataclass
class CIConfig:
"""
Result of CI configuration discovery.
Attributes:
ci_system: Name of CI system (github_actions, gitlab, circleci, jenkins)
config_files: List of CI config files found
test_commands: Extracted test commands by type
coverage_command: Coverage command if found
workflows: List of discovered workflows
environment_variables: Environment variables used
"""
ci_system: str
config_files: list[str] = field(default_factory=list)
test_commands: dict[str, str] = field(default_factory=dict)
coverage_command: str | None = None
workflows: list[CIWorkflow] = field(default_factory=list)
environment_variables: list[str] = field(default_factory=list)
# =============================================================================
# CI PARSERS
# =============================================================================
class CIDiscovery:
"""
Discovers CI/CD configurations in a project.
Analyzes:
- GitHub Actions (.github/workflows/*.yml)
- GitLab CI (.gitlab-ci.yml)
- CircleCI (.circleci/config.yml)
- Jenkins (Jenkinsfile)
"""
def __init__(self) -> None:
"""Initialize CI discovery."""
self._cache: dict[str, CIConfig | None] = {}
def discover(self, project_dir: Path) -> CIConfig | None:
"""
Discover CI configuration in the project.
Args:
project_dir: Path to the project root
Returns:
CIConfig if CI found, None otherwise
"""
project_dir = Path(project_dir)
cache_key = str(project_dir.resolve())
if cache_key in self._cache:
return self._cache[cache_key]
# Try each CI system
result = None
# GitHub Actions
github_workflows = project_dir / ".github" / "workflows"
if github_workflows.exists():
result = self._parse_github_actions(github_workflows)
# GitLab CI
if not result:
gitlab_ci = project_dir / ".gitlab-ci.yml"
if gitlab_ci.exists():
result = self._parse_gitlab_ci(gitlab_ci)
# CircleCI
if not result:
circleci = project_dir / ".circleci" / "config.yml"
if circleci.exists():
result = self._parse_circleci(circleci)
# Jenkins
if not result:
jenkinsfile = project_dir / "Jenkinsfile"
if jenkinsfile.exists():
result = self._parse_jenkinsfile(jenkinsfile)
self._cache[cache_key] = result
return result
def _parse_github_actions(self, workflows_dir: Path) -> CIConfig:
"""Parse GitHub Actions workflow files."""
result = CIConfig(ci_system="github_actions")
workflow_files = list(workflows_dir.glob("*.yml")) + list(
workflows_dir.glob("*.yaml")
)
for wf_file in workflow_files:
result.config_files.append(
str(wf_file.relative_to(workflows_dir.parent.parent))
)
try:
content = wf_file.read_text()
workflow_data = self._parse_yaml(content)
if not workflow_data:
continue
# Get workflow name
wf_name = workflow_data.get("name", wf_file.stem)
# Get triggers
triggers = []
on_trigger = workflow_data.get("on", {})
if isinstance(on_trigger, str):
triggers = [on_trigger]
elif isinstance(on_trigger, list):
triggers = on_trigger
elif isinstance(on_trigger, dict):
triggers = list(on_trigger.keys())
# Parse jobs
jobs = workflow_data.get("jobs", {})
for job_name, job_config in jobs.items():
if not isinstance(job_config, dict):
continue
steps = job_config.get("steps", [])
step_commands = []
test_related = False
for step in steps:
if not isinstance(step, dict):
continue
# Get step name or command
step_name = step.get("name", "")
run_cmd = step.get("run", "")
uses = step.get("uses", "")
if step_name:
step_commands.append(step_name)
if run_cmd:
step_commands.append(run_cmd)
# Extract test commands
self._extract_test_commands(run_cmd, result)
if uses:
step_commands.append(f"uses: {uses}")
# Check if test-related
test_keywords = ["test", "pytest", "jest", "vitest", "coverage"]
if any(kw in str(step).lower() for kw in test_keywords):
test_related = True
result.workflows.append(
CIWorkflow(
name=f"{wf_name}/{job_name}",
trigger=triggers,
steps=step_commands,
test_related=test_related,
)
)
# Extract environment variables
env = workflow_data.get("env", {})
if isinstance(env, dict):
result.environment_variables.extend(env.keys())
except Exception:
continue
return result
def _parse_gitlab_ci(self, config_file: Path) -> CIConfig:
"""Parse GitLab CI configuration."""
result = CIConfig(
ci_system="gitlab",
config_files=[".gitlab-ci.yml"],
)
try:
content = config_file.read_text()
data = self._parse_yaml(content)
if not data:
return result
# Parse jobs (top-level keys that aren't special keywords)
special_keys = {
"stages",
"variables",
"image",
"services",
"before_script",
"after_script",
"cache",
"include",
"default",
"workflow",
}
for key, value in data.items():
if key.startswith(".") or key in special_keys:
continue
if not isinstance(value, dict):
continue
job_config = value
script = job_config.get("script", [])
if isinstance(script, str):
script = [script]
test_related = any(
kw in str(script).lower()
for kw in ["test", "pytest", "jest", "vitest", "coverage"]
)
result.workflows.append(
CIWorkflow(
name=key,
trigger=job_config.get("only", [])
or job_config.get("rules", []),
steps=script,
test_related=test_related,
)
)
# Extract test commands
for cmd in script:
if isinstance(cmd, str):
self._extract_test_commands(cmd, result)
# Extract variables
variables = data.get("variables", {})
if isinstance(variables, dict):
result.environment_variables.extend(variables.keys())
except Exception:
pass
return result
def _parse_circleci(self, config_file: Path) -> CIConfig:
"""Parse CircleCI configuration."""
result = CIConfig(
ci_system="circleci",
config_files=[".circleci/config.yml"],
)
try:
content = config_file.read_text()
data = self._parse_yaml(content)
if not data:
return result
# Parse jobs
jobs = data.get("jobs", {})
for job_name, job_config in jobs.items():
if not isinstance(job_config, dict):
continue
steps = job_config.get("steps", [])
step_commands = []
test_related = False
for step in steps:
if isinstance(step, str):
step_commands.append(step)
elif isinstance(step, dict):
if "run" in step:
run = step["run"]
if isinstance(run, str):
step_commands.append(run)
self._extract_test_commands(run, result)
elif isinstance(run, dict):
cmd = run.get("command", "")
step_commands.append(cmd)
self._extract_test_commands(cmd, result)
if any(
kw in str(step).lower()
for kw in ["test", "pytest", "jest", "coverage"]
):
test_related = True
result.workflows.append(
CIWorkflow(
name=job_name,
trigger=[],
steps=step_commands,
test_related=test_related,
)
)
except Exception:
pass
return result
def _parse_jenkinsfile(self, jenkinsfile: Path) -> CIConfig:
"""Parse Jenkinsfile (basic extraction)."""
result = CIConfig(
ci_system="jenkins",
config_files=["Jenkinsfile"],
)
try:
content = jenkinsfile.read_text()
# Extract sh commands using regex
sh_pattern = re.compile(r'sh\s+[\'"]([^\'"]+)[\'"]')
matches = sh_pattern.findall(content)
steps = []
test_related = False
for cmd in matches:
steps.append(cmd)
self._extract_test_commands(cmd, result)
if any(
kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"]
):
test_related = True
# Extract stage names
stage_pattern = re.compile(r'stage\s*\([\'"]([^\'"]+)[\'"]\)')
stages = stage_pattern.findall(content)
for stage in stages:
result.workflows.append(
CIWorkflow(
name=stage,
trigger=[],
steps=steps if "test" in stage.lower() else [],
test_related="test" in stage.lower(),
)
)
except Exception:
pass
return result
def _parse_yaml(self, content: str) -> dict | None:
"""Parse YAML content, with fallback to basic parsing if yaml not available."""
if HAS_YAML:
try:
return yaml.safe_load(content)
except Exception:
return None
# Basic fallback for simple YAML (very limited)
# This won't work for complex structures
return None
def _extract_test_commands(self, cmd: str, result: CIConfig) -> None:
"""Extract test commands from a command string."""
cmd_lower = cmd.lower()
# Python pytest
if "pytest" in cmd_lower:
if "pytest" not in result.test_commands:
result.test_commands["unit"] = cmd.strip()
if "--cov" in cmd_lower:
result.coverage_command = cmd.strip()
# Node.js test commands
if (
"npm test" in cmd_lower
or "yarn test" in cmd_lower
or "pnpm test" in cmd_lower
):
if "unit" not in result.test_commands:
result.test_commands["unit"] = cmd.strip()
# Jest/Vitest
if "jest" in cmd_lower or "vitest" in cmd_lower:
if "unit" not in result.test_commands:
result.test_commands["unit"] = cmd.strip()
if "--coverage" in cmd_lower:
result.coverage_command = cmd.strip()
# E2E testing
if "playwright" in cmd_lower:
result.test_commands["e2e"] = cmd.strip()
if "cypress" in cmd_lower:
result.test_commands["e2e"] = cmd.strip()
# Integration tests
if "integration" in cmd_lower:
result.test_commands["integration"] = cmd.strip()
# Go tests
if "go test" in cmd_lower:
if "unit" not in result.test_commands:
result.test_commands["unit"] = cmd.strip()
# Rust tests
if "cargo test" in cmd_lower:
if "unit" not in result.test_commands:
result.test_commands["unit"] = cmd.strip()
def to_dict(self, result: CIConfig) -> dict[str, Any]:
"""Convert result to dictionary for JSON serialization."""
return {
"ci_system": result.ci_system,
"config_files": result.config_files,
"test_commands": result.test_commands,
"coverage_command": result.coverage_command,
"workflows": [
{
"name": w.name,
"trigger": w.trigger,
"steps": w.steps,
"test_related": w.test_related,
}
for w in result.workflows
],
"environment_variables": result.environment_variables,
}
def clear_cache(self) -> None:
"""Clear the internal cache."""
self._cache.clear()
# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================
def discover_ci(project_dir: Path) -> CIConfig | None:
"""
Convenience function to discover CI configuration.
Args:
project_dir: Path to project root
Returns:
CIConfig if found, None otherwise
"""
discovery = CIDiscovery()
return discovery.discover(project_dir)
def get_ci_test_commands(project_dir: Path) -> dict[str, str]:
"""
Get test commands from CI configuration.
Args:
project_dir: Path to project root
Returns:
Dictionary of test type to command
"""
discovery = CIDiscovery()
result = discovery.discover(project_dir)
if result:
return result.test_commands
return {}
def get_ci_system(project_dir: Path) -> str | None:
"""
Get the CI system name if configured.
Args:
project_dir: Path to project root
Returns:
CI system name or None
"""
discovery = CIDiscovery()
result = discovery.discover(project_dir)
if result:
return result.ci_system
return None
# =============================================================================
# CLI
# =============================================================================
def main() -> None:
"""CLI entry point for testing."""
import argparse
parser = argparse.ArgumentParser(description="Discover CI configuration")
parser.add_argument("project_dir", type=Path, help="Path to project root")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
discovery = CIDiscovery()
result = discovery.discover(args.project_dir)
if not result:
print("No CI configuration found")
return
if args.json:
print(json.dumps(discovery.to_dict(result), indent=2))
else:
print(f"CI System: {result.ci_system}")
print(f"Config Files: {', '.join(result.config_files)}")
print("\nTest Commands:")
for test_type, cmd in result.test_commands.items():
print(f" {test_type}: {cmd}")
if result.coverage_command:
print(f"\nCoverage Command: {result.coverage_command}")
print(f"\nWorkflows ({len(result.workflows)}):")
for w in result.workflows:
marker = "[TEST]" if w.test_related else ""
print(f" - {w.name} {marker}")
if w.trigger:
print(f" Triggers: {', '.join(str(t) for t in w.trigger)}")
if result.environment_variables:
print(f"\nEnvironment Variables: {', '.join(result.environment_variables)}")
if __name__ == "__main__":
main()
+593
View File
@@ -0,0 +1,593 @@
"""
Insight Extractor
=================
Automatically extracts structured insights from completed coding sessions.
Runs after each session to capture rich, actionable knowledge for Graphiti memory.
Uses the Claude Agent SDK (same as the rest of the system) for extraction.
Falls back to generic insights if extraction fails (never blocks the build).
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Check for Claude SDK availability
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
SDK_AVAILABLE = True
except ImportError:
SDK_AVAILABLE = False
ClaudeAgentOptions = None
ClaudeSDKClient = None
from core.auth import ensure_claude_code_oauth_token, get_auth_token
# Default model for insight extraction (fast and cheap)
DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest"
# Maximum diff size to send to the LLM (avoid context limits)
MAX_DIFF_CHARS = 15000
# Maximum attempt history entries to include
MAX_ATTEMPTS_TO_INCLUDE = 3
def is_extraction_enabled() -> bool:
"""Check if insight extraction is enabled."""
# Extraction requires Claude SDK and authentication token
if not SDK_AVAILABLE:
return False
if not get_auth_token():
return False
enabled_str = os.environ.get("INSIGHT_EXTRACTION_ENABLED", "true").lower()
return enabled_str in ("true", "1", "yes")
def get_extraction_model() -> str:
"""Get the model to use for insight extraction."""
return os.environ.get("INSIGHT_EXTRACTOR_MODEL", DEFAULT_EXTRACTION_MODEL)
# =============================================================================
# Git Helpers
# =============================================================================
def get_session_diff(
project_dir: Path,
commit_before: str | None,
commit_after: str | None,
) -> str:
"""
Get the git diff between two commits.
Args:
project_dir: Project root directory
commit_before: Commit hash before session (or None)
commit_after: Commit hash after session (or None)
Returns:
Diff text (truncated if too large)
"""
if not commit_before or not commit_after:
return "(No commits to diff)"
if commit_before == commit_after:
return "(No changes - same commit)"
try:
result = subprocess.run(
["git", "diff", commit_before, commit_after],
cwd=project_dir,
capture_output=True,
text=True,
timeout=30,
)
diff = result.stdout
if len(diff) > MAX_DIFF_CHARS:
# Truncate and add note
diff = (
diff[:MAX_DIFF_CHARS] + f"\n\n... (truncated, {len(diff)} chars total)"
)
return diff if diff else "(Empty diff)"
except subprocess.TimeoutExpired:
logger.warning("Git diff timed out")
return "(Git diff timed out)"
except Exception as e:
logger.warning(f"Failed to get git diff: {e}")
return f"(Failed to get diff: {e})"
def get_changed_files(
project_dir: Path,
commit_before: str | None,
commit_after: str | None,
) -> list[str]:
"""
Get list of files changed between two commits.
Args:
project_dir: Project root directory
commit_before: Commit hash before session
commit_after: Commit hash after session
Returns:
List of changed file paths
"""
if not commit_before or not commit_after or commit_before == commit_after:
return []
try:
result = subprocess.run(
["git", "diff", "--name-only", commit_before, commit_after],
cwd=project_dir,
capture_output=True,
text=True,
timeout=10,
)
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
return files
except Exception as e:
logger.warning(f"Failed to get changed files: {e}")
return []
def get_commit_messages(
project_dir: Path,
commit_before: str | None,
commit_after: str | None,
) -> str:
"""Get commit messages between two commits."""
if not commit_before or not commit_after or commit_before == commit_after:
return "(No commits)"
try:
result = subprocess.run(
["git", "log", "--oneline", f"{commit_before}..{commit_after}"],
cwd=project_dir,
capture_output=True,
text=True,
timeout=10,
)
return result.stdout.strip() if result.stdout.strip() else "(No commits)"
except Exception as e:
logger.warning(f"Failed to get commit messages: {e}")
return f"(Failed: {e})"
# =============================================================================
# Input Gathering
# =============================================================================
def gather_extraction_inputs(
spec_dir: Path,
project_dir: Path,
subtask_id: str,
session_num: int,
commit_before: str | None,
commit_after: str | None,
success: bool,
recovery_manager: Any,
) -> dict:
"""
Gather all inputs needed for insight extraction.
Args:
spec_dir: Spec directory
project_dir: Project root
subtask_id: The subtask that was worked on
session_num: Session number
commit_before: Commit before session
commit_after: Commit after session
success: Whether session succeeded
recovery_manager: Recovery manager with attempt history
Returns:
Dict with all inputs for the extractor
"""
# Get subtask description from implementation plan
subtask_description = _get_subtask_description(spec_dir, subtask_id)
# Get git diff
diff = get_session_diff(project_dir, commit_before, commit_after)
# Get changed files
changed_files = get_changed_files(project_dir, commit_before, commit_after)
# Get commit messages
commit_messages = get_commit_messages(project_dir, commit_before, commit_after)
# Get attempt history
attempt_history = _get_attempt_history(recovery_manager, subtask_id)
return {
"subtask_id": subtask_id,
"subtask_description": subtask_description,
"session_num": session_num,
"success": success,
"diff": diff,
"changed_files": changed_files,
"commit_messages": commit_messages,
"attempt_history": attempt_history,
}
def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str:
"""Get subtask description from implementation plan."""
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return f"Subtask: {subtask_id}"
try:
with open(plan_file) as f:
plan = json.load(f)
# Search through phases for the subtask
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
return subtask.get("description", f"Subtask: {subtask_id}")
return f"Subtask: {subtask_id}"
except Exception as e:
logger.warning(f"Failed to load subtask description: {e}")
return f"Subtask: {subtask_id}"
def _get_attempt_history(recovery_manager: Any, subtask_id: str) -> list[dict]:
"""Get previous attempt history for this subtask."""
if not recovery_manager:
return []
try:
history = recovery_manager.get_subtask_history(subtask_id)
attempts = history.get("attempts", [])
# Limit to recent attempts
return attempts[-MAX_ATTEMPTS_TO_INCLUDE:]
except Exception as e:
logger.warning(f"Failed to get attempt history: {e}")
return []
# =============================================================================
# LLM Extraction
# =============================================================================
def _build_extraction_prompt(inputs: dict) -> str:
"""Build the prompt for insight extraction."""
prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md"
if prompt_file.exists():
base_prompt = prompt_file.read_text()
else:
# Fallback if prompt file missing
base_prompt = """Extract structured insights from this coding session.
Output ONLY valid JSON with: file_insights, patterns_discovered, gotchas_discovered, approach_outcome, recommendations"""
# Build session context
session_context = f"""
---
## SESSION DATA
### Subtask
- **ID**: {inputs["subtask_id"]}
- **Description**: {inputs["subtask_description"]}
- **Session Number**: {inputs["session_num"]}
- **Outcome**: {"SUCCESS" if inputs["success"] else "FAILED"}
### Files Changed
{chr(10).join(f"- {f}" for f in inputs["changed_files"]) if inputs["changed_files"] else "(No files changed)"}
### Commit Messages
{inputs["commit_messages"]}
### Git Diff
```diff
{inputs["diff"]}
```
### Previous Attempts
{_format_attempt_history(inputs["attempt_history"])}
---
Now analyze this session and output ONLY the JSON object.
"""
return base_prompt + session_context
def _format_attempt_history(attempts: list[dict]) -> str:
"""Format attempt history for the prompt."""
if not attempts:
return "(First attempt - no previous history)"
lines = []
for i, attempt in enumerate(attempts, 1):
success = "SUCCESS" if attempt.get("success") else "FAILED"
approach = attempt.get("approach", "Unknown approach")
error = attempt.get("error", "")
lines.append(f"**Attempt {i}** ({success}): {approach}")
if error:
lines.append(f" Error: {error}")
return "\n".join(lines)
async def run_insight_extraction(
inputs: dict, project_dir: Path | None = None
) -> dict | None:
"""
Run the insight extraction using Claude Agent SDK.
Args:
inputs: Gathered session inputs
project_dir: Project directory for SDK context (optional)
Returns:
Extracted insights dict or None if failed
"""
if not SDK_AVAILABLE:
logger.warning("Claude SDK not available, skipping insight extraction")
return None
if not get_auth_token():
logger.warning("No authentication token found, skipping insight extraction")
return None
# Ensure SDK can find the token
ensure_claude_code_oauth_token()
model = get_extraction_model()
prompt = _build_extraction_prompt(inputs)
# Use current directory if project_dir not specified
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 async context manager
async with client:
await client.query(prompt)
# Collect the response
response_text = ""
async for msg in client.receive_response():
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
if hasattr(block, "text"):
response_text += block.text
# Parse JSON from response
return parse_insights(response_text)
except Exception as e:
logger.warning(f"Insight extraction failed: {e}")
return None
def parse_insights(response_text: str) -> dict | None:
"""
Parse the LLM response into structured insights.
Args:
response_text: Raw LLM response
Returns:
Parsed insights dict or None if parsing failed
"""
# Try to extract JSON from the response
text = response_text.strip()
# Handle markdown code blocks
if text.startswith("```"):
# Remove code block markers
lines = text.split("\n")
# Remove first line (```json or ```)
if lines[0].startswith("```"):
lines = lines[1:]
# Remove last line if it's ``
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines)
try:
insights = json.loads(text)
# Validate structure
if not isinstance(insights, dict):
logger.warning("Insights is not a dict")
return None
# Ensure required keys exist with defaults
insights.setdefault("file_insights", [])
insights.setdefault("patterns_discovered", [])
insights.setdefault("gotchas_discovered", [])
insights.setdefault("approach_outcome", {})
insights.setdefault("recommendations", [])
return insights
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse insights JSON: {e}")
logger.debug(f"Response text was: {text[:500]}")
return None
# =============================================================================
# Main Entry Point
# =============================================================================
async def extract_session_insights(
spec_dir: Path,
project_dir: Path,
subtask_id: str,
session_num: int,
commit_before: str | None,
commit_after: str | None,
success: bool,
recovery_manager: Any,
) -> dict:
"""
Extract insights from a completed coding session.
This is the main entry point called from post_session_processing().
Falls back to generic insights if extraction fails.
Args:
spec_dir: Spec directory
project_dir: Project root
subtask_id: Subtask that was worked on
session_num: Session number
commit_before: Commit before session
commit_after: Commit after session
success: Whether session succeeded
recovery_manager: Recovery manager with attempt history
Returns:
Insights dict (rich if extraction succeeded, generic if failed)
"""
# Check if extraction is enabled
if not is_extraction_enabled():
logger.info("Insight extraction disabled")
return _get_generic_insights(subtask_id, success)
# Check for no changes
if commit_before == commit_after:
logger.info("No changes to extract insights from")
return _get_generic_insights(subtask_id, success)
try:
# Gather inputs
inputs = gather_extraction_inputs(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
session_num=session_num,
commit_before=commit_before,
commit_after=commit_after,
success=success,
recovery_manager=recovery_manager,
)
# Run extraction
extracted = await run_insight_extraction(inputs, project_dir=project_dir)
if extracted:
# Add metadata
extracted["subtask_id"] = subtask_id
extracted["session_num"] = session_num
extracted["success"] = success
extracted["changed_files"] = inputs["changed_files"]
logger.info(
f"Extracted insights: {len(extracted.get('file_insights', []))} file insights, "
f"{len(extracted.get('patterns_discovered', []))} patterns, "
f"{len(extracted.get('gotchas_discovered', []))} gotchas"
)
return extracted
else:
logger.warning("Extraction returned no results, using generic insights")
return _get_generic_insights(subtask_id, success)
except Exception as e:
logger.warning(f"Insight extraction failed: {e}, using generic insights")
return _get_generic_insights(subtask_id, success)
def _get_generic_insights(subtask_id: str, success: bool) -> dict:
"""Return generic insights when extraction fails or is disabled."""
return {
"file_insights": [],
"patterns_discovered": [],
"gotchas_discovered": [],
"approach_outcome": {
"success": success,
"approach_used": f"Implemented subtask: {subtask_id}",
"why_it_worked": None,
"why_it_failed": None,
"alternatives_tried": [],
},
"recommendations": [],
"subtask_id": subtask_id,
"success": success,
"changed_files": [],
}
# =============================================================================
# CLI for Testing
# =============================================================================
if __name__ == "__main__":
import argparse
import asyncio
parser = argparse.ArgumentParser(description="Test insight extraction")
parser.add_argument("--spec-dir", type=Path, required=True, help="Spec directory")
parser.add_argument(
"--project-dir", type=Path, required=True, help="Project directory"
)
parser.add_argument(
"--commit-before", type=str, required=True, help="Commit before session"
)
parser.add_argument(
"--commit-after", type=str, required=True, help="Commit after session"
)
parser.add_argument(
"--subtask-id", type=str, default="test-subtask", help="Subtask ID"
)
args = parser.parse_args()
async def main():
insights = await extract_session_insights(
spec_dir=args.spec_dir,
project_dir=args.project_dir,
subtask_id=args.subtask_id,
session_num=1,
commit_before=args.commit_before,
commit_after=args.commit_after,
success=True,
recovery_manager=None,
)
print(json.dumps(insights, indent=2))
asyncio.run(main())
+109
View File
@@ -0,0 +1,109 @@
"""
Smart Project Analyzer for Dynamic Security Profiles
=====================================================
FACADE MODULE: This module re-exports all functionality from the
auto-claude/project/ package for backward compatibility.
The implementation has been refactored into focused modules:
- project/command_registry.py - Command registries
- project/models.py - Data structures
- project/config_parser.py - Config file parsing
- project/stack_detector.py - Stack detection
- project/framework_detector.py - Framework detection
- project/structure_analyzer.py - Project structure analysis
- project/analyzer.py - Main orchestration
This file maintains the original API so existing imports continue to work.
This system:
1. Detects languages, frameworks, databases, and infrastructure
2. Parses package.json scripts, Makefile targets, pyproject.toml scripts
3. Builds a tailored security profile for the specific project
4. Caches the profile for subsequent runs
5. Can re-analyze when project structure changes
The goal: Allow an AI developer to run any command that's legitimately
needed for the detected tech stack, while blocking dangerous operations.
"""
# Re-export all public API from the project module
from __future__ import annotations
from project import (
# Command registries
BASE_COMMANDS,
VALIDATED_COMMANDS,
CustomScripts,
# Main classes
ProjectAnalyzer,
SecurityProfile,
TechnologyStack,
# Utility functions
get_or_create_profile,
is_command_allowed,
needs_validation,
)
# Also re-export command registries for backward compatibility
from project.command_registry import (
CLOUD_COMMANDS,
CODE_QUALITY_COMMANDS,
DATABASE_COMMANDS,
FRAMEWORK_COMMANDS,
INFRASTRUCTURE_COMMANDS,
LANGUAGE_COMMANDS,
PACKAGE_MANAGER_COMMANDS,
VERSION_MANAGER_COMMANDS,
)
__all__ = [
# Main classes
"ProjectAnalyzer",
"SecurityProfile",
"TechnologyStack",
"CustomScripts",
# Utility functions
"get_or_create_profile",
"is_command_allowed",
"needs_validation",
# Base command sets
"BASE_COMMANDS",
"VALIDATED_COMMANDS",
# Technology-specific command sets
"LANGUAGE_COMMANDS",
"PACKAGE_MANAGER_COMMANDS",
"FRAMEWORK_COMMANDS",
"DATABASE_COMMANDS",
"INFRASTRUCTURE_COMMANDS",
"CLOUD_COMMANDS",
"CODE_QUALITY_COMMANDS",
"VERSION_MANAGER_COMMANDS",
]
# =============================================================================
# CLI for testing
# =============================================================================
if __name__ == "__main__":
import sys
from pathlib import Path
if len(sys.argv) < 2:
print("Usage: python project_analyzer.py <project_dir> [--force]")
sys.exit(1)
project_dir = Path(sys.argv[1])
force = "--force" in sys.argv
if not project_dir.exists():
print(f"Error: {project_dir} does not exist")
sys.exit(1)
profile = get_or_create_profile(project_dir, force_reanalyze=force)
print("\nAllowed commands:")
for cmd in sorted(profile.get_all_allowed_commands()):
print(f" {cmd}")
+591
View File
@@ -0,0 +1,591 @@
#!/usr/bin/env python3
"""
Risk Classifier Module
======================
Reads the AI-generated complexity_assessment.json and provides programmatic
access to risk classification and validation recommendations.
This module serves as the bridge between the AI complexity assessor prompt
and the rest of the validation system.
Usage:
from risk_classifier import RiskClassifier
classifier = RiskClassifier()
assessment = classifier.load_assessment(spec_dir)
if classifier.should_skip_validation(spec_dir):
print("Validation can be skipped for this task")
test_types = classifier.get_required_test_types(spec_dir)
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class ScopeAnalysis:
"""Analysis of task scope."""
estimated_files: int = 0
estimated_services: int = 0
is_cross_cutting: bool = False
notes: str = ""
@dataclass
class IntegrationAnalysis:
"""Analysis of external integrations."""
external_services: list[str] = field(default_factory=list)
new_dependencies: list[str] = field(default_factory=list)
research_needed: bool = False
notes: str = ""
@dataclass
class InfrastructureAnalysis:
"""Analysis of infrastructure requirements."""
docker_changes: bool = False
database_changes: bool = False
config_changes: bool = False
notes: str = ""
@dataclass
class KnowledgeAnalysis:
"""Analysis of knowledge requirements."""
patterns_exist: bool = True
research_required: bool = False
unfamiliar_tech: list[str] = field(default_factory=list)
notes: str = ""
@dataclass
class RiskAnalysis:
"""Analysis of task risk."""
level: str = "low" # low, medium, high
concerns: list[str] = field(default_factory=list)
notes: str = ""
@dataclass
class ComplexityAnalysis:
"""Full complexity analysis from the AI assessor."""
scope: ScopeAnalysis = field(default_factory=ScopeAnalysis)
integrations: IntegrationAnalysis = field(default_factory=IntegrationAnalysis)
infrastructure: InfrastructureAnalysis = field(
default_factory=InfrastructureAnalysis
)
knowledge: KnowledgeAnalysis = field(default_factory=KnowledgeAnalysis)
risk: RiskAnalysis = field(default_factory=RiskAnalysis)
@dataclass
class ValidationRecommendations:
"""Validation recommendations from the AI assessor."""
risk_level: str = "medium" # trivial, low, medium, high, critical
skip_validation: bool = False
minimal_mode: bool = False
test_types_required: list[str] = field(default_factory=lambda: ["unit"])
security_scan_required: bool = False
staging_deployment_required: bool = False
reasoning: str = ""
@dataclass
class AssessmentFlags:
"""Flags indicating special requirements."""
needs_research: bool = False
needs_self_critique: bool = False
needs_infrastructure_setup: bool = False
@dataclass
class RiskAssessment:
"""Complete risk assessment from complexity_assessment.json."""
complexity: str # simple, standard, complex
workflow_type: str # feature, refactor, investigation, migration, simple
confidence: float
reasoning: str
analysis: ComplexityAnalysis
recommended_phases: list[str]
flags: AssessmentFlags
validation: ValidationRecommendations
created_at: str | None = None
@property
def risk_level(self) -> str:
"""Get the risk level from validation recommendations."""
return self.validation.risk_level
# =============================================================================
# RISK CLASSIFIER
# =============================================================================
class RiskClassifier:
"""
Reads AI-generated complexity_assessment.json and provides risk classification.
The complexity_assessment.json is generated by the AI complexity assessor
agent using the complexity_assessor.md prompt. This module parses that output
and provides programmatic access to the risk classification.
"""
def __init__(self) -> None:
"""Initialize the risk classifier."""
self._cache: dict[str, RiskAssessment] = {}
def load_assessment(self, spec_dir: Path) -> RiskAssessment | None:
"""
Load complexity_assessment.json from spec directory.
Args:
spec_dir: Path to the spec directory containing complexity_assessment.json
Returns:
RiskAssessment object if file exists and is valid, None otherwise
"""
spec_dir = Path(spec_dir)
cache_key = str(spec_dir.resolve())
# Return cached result if available
if cache_key in self._cache:
return self._cache[cache_key]
assessment_file = spec_dir / "complexity_assessment.json"
if not assessment_file.exists():
return None
try:
with open(assessment_file, encoding="utf-8") as f:
data = json.load(f)
assessment = self._parse_assessment(data)
self._cache[cache_key] = assessment
return assessment
except (json.JSONDecodeError, KeyError, TypeError) as e:
# Log error but don't crash - return None to allow fallback behavior
print(f"Warning: Failed to parse complexity_assessment.json: {e}")
return None
def _parse_assessment(self, data: dict[str, Any]) -> RiskAssessment:
"""Parse raw JSON data into a RiskAssessment object."""
# Parse analysis sections
analysis_data = data.get("analysis", {})
analysis = ComplexityAnalysis(
scope=self._parse_scope(analysis_data.get("scope", {})),
integrations=self._parse_integrations(
analysis_data.get("integrations", {})
),
infrastructure=self._parse_infrastructure(
analysis_data.get("infrastructure", {})
),
knowledge=self._parse_knowledge(analysis_data.get("knowledge", {})),
risk=self._parse_risk(analysis_data.get("risk", {})),
)
# Parse flags
flags_data = data.get("flags", {})
flags = AssessmentFlags(
needs_research=flags_data.get("needs_research", False),
needs_self_critique=flags_data.get("needs_self_critique", False),
needs_infrastructure_setup=flags_data.get(
"needs_infrastructure_setup", False
),
)
# Parse validation recommendations
validation_data = data.get("validation_recommendations", {})
validation = self._parse_validation_recommendations(validation_data, analysis)
return RiskAssessment(
complexity=data.get("complexity", "standard"),
workflow_type=data.get("workflow_type", "feature"),
confidence=float(data.get("confidence", 0.5)),
reasoning=data.get("reasoning", ""),
analysis=analysis,
recommended_phases=data.get("recommended_phases", []),
flags=flags,
validation=validation,
created_at=data.get("created_at"),
)
def _parse_scope(self, data: dict[str, Any]) -> ScopeAnalysis:
"""Parse scope analysis section."""
return ScopeAnalysis(
estimated_files=int(data.get("estimated_files", 0)),
estimated_services=int(data.get("estimated_services", 0)),
is_cross_cutting=bool(data.get("is_cross_cutting", False)),
notes=str(data.get("notes", "")),
)
def _parse_integrations(self, data: dict[str, Any]) -> IntegrationAnalysis:
"""Parse integrations analysis section."""
return IntegrationAnalysis(
external_services=list(data.get("external_services", [])),
new_dependencies=list(data.get("new_dependencies", [])),
research_needed=bool(data.get("research_needed", False)),
notes=str(data.get("notes", "")),
)
def _parse_infrastructure(self, data: dict[str, Any]) -> InfrastructureAnalysis:
"""Parse infrastructure analysis section."""
return InfrastructureAnalysis(
docker_changes=bool(data.get("docker_changes", False)),
database_changes=bool(data.get("database_changes", False)),
config_changes=bool(data.get("config_changes", False)),
notes=str(data.get("notes", "")),
)
def _parse_knowledge(self, data: dict[str, Any]) -> KnowledgeAnalysis:
"""Parse knowledge analysis section."""
return KnowledgeAnalysis(
patterns_exist=bool(data.get("patterns_exist", True)),
research_required=bool(data.get("research_required", False)),
unfamiliar_tech=list(data.get("unfamiliar_tech", [])),
notes=str(data.get("notes", "")),
)
def _parse_risk(self, data: dict[str, Any]) -> RiskAnalysis:
"""Parse risk analysis section."""
return RiskAnalysis(
level=str(data.get("level", "low")),
concerns=list(data.get("concerns", [])),
notes=str(data.get("notes", "")),
)
def _parse_validation_recommendations(
self, data: dict[str, Any], analysis: ComplexityAnalysis
) -> ValidationRecommendations:
"""
Parse validation recommendations section.
If validation_recommendations is not present in the JSON (older assessments),
infer appropriate values from the analysis.
"""
if data:
# New format with explicit validation recommendations
return ValidationRecommendations(
risk_level=str(data.get("risk_level", "medium")),
skip_validation=bool(data.get("skip_validation", False)),
minimal_mode=bool(data.get("minimal_mode", False)),
test_types_required=list(data.get("test_types_required", ["unit"])),
security_scan_required=bool(data.get("security_scan_required", False)),
staging_deployment_required=bool(
data.get("staging_deployment_required", False)
),
reasoning=str(data.get("reasoning", "")),
)
else:
# Infer from analysis (backward compatibility)
return self._infer_validation_recommendations(analysis)
def _infer_validation_recommendations(
self, analysis: ComplexityAnalysis
) -> ValidationRecommendations:
"""
Infer validation recommendations from analysis when not explicitly provided.
This provides backward compatibility with older complexity assessments
that don't have the validation_recommendations section.
"""
risk_level = analysis.risk.level
# Map old risk levels to new ones
risk_mapping = {
"low": "low",
"medium": "medium",
"high": "high",
}
normalized_risk = risk_mapping.get(risk_level, "medium")
# Infer test types based on risk
test_types_map = {
"low": ["unit"],
"medium": ["unit", "integration"],
"high": ["unit", "integration", "e2e"],
}
test_types = test_types_map.get(normalized_risk, ["unit", "integration"])
# Security scan for high risk or security-related concerns
security_keywords = [
"security",
"auth",
"password",
"credential",
"token",
"api key",
]
has_security_concerns = any(
kw in str(analysis.risk.concerns).lower() for kw in security_keywords
)
security_scan_required = normalized_risk == "high" or has_security_concerns
# Staging for database or infrastructure changes
staging_required = (
analysis.infrastructure.database_changes
and normalized_risk in ["medium", "high"]
)
# Minimal mode for simple changes
minimal_mode = (
analysis.scope.estimated_files <= 2
and analysis.scope.estimated_services <= 1
and not analysis.integrations.external_services
)
return ValidationRecommendations(
risk_level=normalized_risk,
skip_validation=False, # Never skip by inference
minimal_mode=minimal_mode,
test_types_required=test_types,
security_scan_required=security_scan_required,
staging_deployment_required=staging_required,
reasoning="Inferred from complexity analysis (no explicit recommendations found)",
)
def should_skip_validation(self, spec_dir: Path) -> bool:
"""
Quick check if validation can be skipped entirely.
Args:
spec_dir: Path to the spec directory
Returns:
True if validation can be skipped (trivial changes), False otherwise
"""
assessment = self.load_assessment(spec_dir)
if not assessment:
return False # When in doubt, don't skip
return assessment.validation.skip_validation
def should_use_minimal_mode(self, spec_dir: Path) -> bool:
"""
Check if minimal validation mode should be used.
Args:
spec_dir: Path to the spec directory
Returns:
True if minimal mode is recommended, False otherwise
"""
assessment = self.load_assessment(spec_dir)
if not assessment:
return False
return assessment.validation.minimal_mode
def get_required_test_types(self, spec_dir: Path) -> list[str]:
"""
Get list of required test types based on risk.
Args:
spec_dir: Path to the spec directory
Returns:
List of test types (e.g., ["unit", "integration", "e2e"])
"""
assessment = self.load_assessment(spec_dir)
if not assessment:
return ["unit"] # Default to unit tests
return assessment.validation.test_types_required
def requires_security_scan(self, spec_dir: Path) -> bool:
"""
Check if security scanning is required.
Args:
spec_dir: Path to the spec directory
Returns:
True if security scan is required, False otherwise
"""
assessment = self.load_assessment(spec_dir)
if not assessment:
return False
return assessment.validation.security_scan_required
def requires_staging_deployment(self, spec_dir: Path) -> bool:
"""
Check if staging deployment is required.
Args:
spec_dir: Path to the spec directory
Returns:
True if staging deployment is required, False otherwise
"""
assessment = self.load_assessment(spec_dir)
if not assessment:
return False
return assessment.validation.staging_deployment_required
def get_risk_level(self, spec_dir: Path) -> str:
"""
Get the risk level for the task.
Args:
spec_dir: Path to the spec directory
Returns:
Risk level string (trivial, low, medium, high, critical)
"""
assessment = self.load_assessment(spec_dir)
if not assessment:
return "medium" # Default to medium when unknown
return assessment.validation.risk_level
def get_complexity(self, spec_dir: Path) -> str:
"""
Get the complexity level for the task.
Args:
spec_dir: Path to the spec directory
Returns:
Complexity level string (simple, standard, complex)
"""
assessment = self.load_assessment(spec_dir)
if not assessment:
return "standard" # Default to standard when unknown
return assessment.complexity
def get_validation_summary(self, spec_dir: Path) -> dict[str, Any]:
"""
Get a summary of validation requirements.
Args:
spec_dir: Path to the spec directory
Returns:
Dictionary with validation summary
"""
assessment = self.load_assessment(spec_dir)
if not assessment:
return {
"risk_level": "unknown",
"complexity": "unknown",
"skip_validation": False,
"minimal_mode": False,
"test_types": ["unit"],
"security_scan": False,
"staging_deployment": False,
"confidence": 0.0,
}
return {
"risk_level": assessment.validation.risk_level,
"complexity": assessment.complexity,
"skip_validation": assessment.validation.skip_validation,
"minimal_mode": assessment.validation.minimal_mode,
"test_types": assessment.validation.test_types_required,
"security_scan": assessment.validation.security_scan_required,
"staging_deployment": assessment.validation.staging_deployment_required,
"confidence": assessment.confidence,
"reasoning": assessment.validation.reasoning,
}
def clear_cache(self) -> None:
"""Clear the internal cache of loaded assessments."""
self._cache.clear()
# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================
def load_risk_assessment(spec_dir: Path) -> RiskAssessment | None:
"""
Convenience function to load a risk assessment.
Args:
spec_dir: Path to the spec directory
Returns:
RiskAssessment object or None
"""
classifier = RiskClassifier()
return classifier.load_assessment(spec_dir)
def get_validation_requirements(spec_dir: Path) -> dict[str, Any]:
"""
Convenience function to get validation requirements.
Args:
spec_dir: Path to the spec directory
Returns:
Dictionary with validation requirements
"""
classifier = RiskClassifier()
return classifier.get_validation_summary(spec_dir)
# =============================================================================
# CLI
# =============================================================================
def main() -> None:
"""CLI entry point for testing."""
import argparse
parser = argparse.ArgumentParser(description="Load and display risk assessment")
parser.add_argument(
"spec_dir",
type=Path,
help="Path to spec directory with complexity_assessment.json",
)
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
classifier = RiskClassifier()
summary = classifier.get_validation_summary(args.spec_dir)
if args.json:
print(json.dumps(summary, indent=2))
else:
print(f"Risk Level: {summary['risk_level']}")
print(f"Complexity: {summary['complexity']}")
print(f"Skip Validation: {summary['skip_validation']}")
print(f"Minimal Mode: {summary['minimal_mode']}")
print(f"Test Types: {', '.join(summary['test_types'])}")
print(f"Security Scan: {summary['security_scan']}")
print(f"Staging Deployment: {summary['staging_deployment']}")
print(f"Confidence: {summary['confidence']:.2f}")
if summary.get("reasoning"):
print(f"Reasoning: {summary['reasoning']}")
if __name__ == "__main__":
main()
+599
View File
@@ -0,0 +1,599 @@
#!/usr/bin/env python3
"""
Security Scanner Module
=======================
Consolidates security scanning including secrets detection and SAST tools.
This module integrates the existing scan_secrets.py and provides a unified
interface for all security scanning.
The security scanner is used by:
- QA Agent: To verify no secrets are committed
- Validation Strategy: To run security scans for high-risk changes
Usage:
from analysis.security_scanner import SecurityScanner
scanner = SecurityScanner()
results = scanner.scan(project_dir, spec_dir)
if results.has_critical_issues:
print("Security issues found - blocking QA approval")
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Import the existing secrets scanner
try:
from security.scan_secrets import SecretMatch, get_all_tracked_files, scan_files
HAS_SECRETS_SCANNER = True
except ImportError:
HAS_SECRETS_SCANNER = False
SecretMatch = None
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class SecurityVulnerability:
"""
Represents a security vulnerability found during scanning.
Attributes:
severity: Severity level (critical, high, medium, low, info)
source: Which scanner found this (secrets, bandit, npm_audit, etc.)
title: Short title of the vulnerability
description: Detailed description
file: File where vulnerability was found (if applicable)
line: Line number (if applicable)
cwe: CWE identifier if available
"""
severity: str # critical, high, medium, low, info
source: str # secrets, bandit, npm_audit, semgrep, etc.
title: str
description: str
file: str | None = None
line: int | None = None
cwe: str | None = None
@dataclass
class SecurityScanResult:
"""
Result of a security scan.
Attributes:
secrets: List of detected secrets
vulnerabilities: List of security vulnerabilities
scan_errors: List of errors during scanning
has_critical_issues: Whether any critical issues were found
should_block_qa: Whether these results should block QA approval
"""
secrets: list[dict[str, Any]] = field(default_factory=list)
vulnerabilities: list[SecurityVulnerability] = field(default_factory=list)
scan_errors: list[str] = field(default_factory=list)
has_critical_issues: bool = False
should_block_qa: bool = False
# =============================================================================
# SECURITY SCANNER
# =============================================================================
class SecurityScanner:
"""
Consolidates all security scanning operations.
Integrates:
- scan_secrets.py for secrets detection
- Bandit for Python SAST (if available)
- npm audit for JavaScript vulnerabilities (if applicable)
"""
def __init__(self) -> None:
"""Initialize the security scanner."""
self._bandit_available: bool | None = None
self._npm_available: bool | None = None
def scan(
self,
project_dir: Path,
spec_dir: Path | None = None,
changed_files: list[str] | None = None,
run_secrets: bool = True,
run_sast: bool = True,
run_dependency_audit: bool = True,
) -> SecurityScanResult:
"""
Run all applicable security scans.
Args:
project_dir: Path to the project root
spec_dir: Path to the spec directory (for storing results)
changed_files: Optional list of files to scan (if None, scans all)
run_secrets: Whether to run secrets scanning
run_sast: Whether to run SAST tools
run_dependency_audit: Whether to run dependency audits
Returns:
SecurityScanResult with all findings
"""
project_dir = Path(project_dir)
result = SecurityScanResult()
# Run secrets scan
if run_secrets:
self._run_secrets_scan(project_dir, changed_files, result)
# Run SAST based on project type
if run_sast:
self._run_sast_scans(project_dir, result)
# Run dependency audits
if run_dependency_audit:
self._run_dependency_audits(project_dir, result)
# Determine if should block QA
result.has_critical_issues = (
any(v.severity in ["critical", "high"] for v in result.vulnerabilities)
or len(result.secrets) > 0
)
# Any secrets always block, critical vulnerabilities block
result.should_block_qa = len(result.secrets) > 0 or any(
v.severity == "critical" for v in result.vulnerabilities
)
# Save results if spec_dir provided
if spec_dir:
self._save_results(spec_dir, result)
return result
def _run_secrets_scan(
self,
project_dir: Path,
changed_files: list[str] | None,
result: SecurityScanResult,
) -> None:
"""Run secrets scanning using scan_secrets.py."""
if not HAS_SECRETS_SCANNER:
result.scan_errors.append("scan_secrets module not available")
return
try:
# Get files to scan
if changed_files:
files_to_scan = changed_files
else:
files_to_scan = get_all_tracked_files()
# Run scan
matches = scan_files(files_to_scan, project_dir)
# Convert matches to result format
for match in matches:
result.secrets.append(
{
"file": match.file_path,
"line": match.line_number,
"pattern": match.pattern_name,
"matched_text": self._redact_secret(match.matched_text),
}
)
# Also add as vulnerability
result.vulnerabilities.append(
SecurityVulnerability(
severity="critical",
source="secrets",
title=f"Potential secret: {match.pattern_name}",
description=f"Found potential {match.pattern_name} in file",
file=match.file_path,
line=match.line_number,
)
)
except Exception as e:
result.scan_errors.append(f"Secrets scan error: {str(e)}")
def _run_sast_scans(self, project_dir: Path, result: SecurityScanResult) -> None:
"""Run SAST tools based on project type."""
# Python SAST with Bandit
if self._is_python_project(project_dir):
self._run_bandit(project_dir, result)
# JavaScript/Node.js - npm audit
# (handled in dependency audits for Node projects)
def _run_bandit(self, project_dir: Path, result: SecurityScanResult) -> None:
"""Run Bandit security scanner for Python projects."""
if not self._check_bandit_available():
return
try:
# Find Python source directories
src_dirs = []
for candidate in ["src", "app", project_dir.name, "."]:
candidate_path = project_dir / candidate
if (
candidate_path.exists()
and (candidate_path / "__init__.py").exists()
):
src_dirs.append(str(candidate_path))
if not src_dirs:
# Try to find any Python files
py_files = list(project_dir.glob("**/*.py"))
if not py_files:
return
src_dirs = ["."]
# Run bandit
cmd = [
"bandit",
"-r",
*src_dirs,
"-f",
"json",
"--exit-zero", # Don't fail on findings
]
proc = subprocess.run(
cmd,
cwd=project_dir,
capture_output=True,
text=True,
timeout=120,
)
if proc.stdout:
try:
bandit_output = json.loads(proc.stdout)
for finding in bandit_output.get("results", []):
severity = finding.get("issue_severity", "MEDIUM").lower()
if severity == "high":
severity = "high"
elif severity == "medium":
severity = "medium"
else:
severity = "low"
result.vulnerabilities.append(
SecurityVulnerability(
severity=severity,
source="bandit",
title=finding.get("issue_text", "Unknown issue"),
description=finding.get("issue_text", ""),
file=finding.get("filename"),
line=finding.get("line_number"),
cwe=finding.get("issue_cwe", {}).get("id"),
)
)
except json.JSONDecodeError:
result.scan_errors.append("Failed to parse Bandit output")
except subprocess.TimeoutExpired:
result.scan_errors.append("Bandit scan timed out")
except FileNotFoundError:
result.scan_errors.append("Bandit not found")
except Exception as e:
result.scan_errors.append(f"Bandit error: {str(e)}")
def _run_dependency_audits(
self, project_dir: Path, result: SecurityScanResult
) -> None:
"""Run dependency vulnerability audits."""
# npm audit for JavaScript projects
if (project_dir / "package.json").exists():
self._run_npm_audit(project_dir, result)
# pip-audit for Python projects (if available)
if self._is_python_project(project_dir):
self._run_pip_audit(project_dir, result)
def _run_npm_audit(self, project_dir: Path, result: SecurityScanResult) -> None:
"""Run npm audit for JavaScript projects."""
try:
cmd = ["npm", "audit", "--json"]
proc = subprocess.run(
cmd,
cwd=project_dir,
capture_output=True,
text=True,
timeout=120,
)
if proc.stdout:
try:
audit_output = json.loads(proc.stdout)
# npm audit v2+ format
vulnerabilities = audit_output.get("vulnerabilities", {})
for pkg_name, vuln_info in vulnerabilities.items():
severity = vuln_info.get("severity", "moderate")
if severity == "critical":
severity = "critical"
elif severity == "high":
severity = "high"
elif severity == "moderate":
severity = "medium"
else:
severity = "low"
result.vulnerabilities.append(
SecurityVulnerability(
severity=severity,
source="npm_audit",
title=f"Vulnerable dependency: {pkg_name}",
description=vuln_info.get("via", [{}])[0].get(
"title", ""
)
if isinstance(vuln_info.get("via"), list)
and vuln_info.get("via")
else str(vuln_info.get("via", "")),
file="package.json",
)
)
except json.JSONDecodeError:
pass # npm audit may return invalid JSON on no findings
except subprocess.TimeoutExpired:
result.scan_errors.append("npm audit timed out")
except FileNotFoundError:
pass # npm not available
except Exception as e:
result.scan_errors.append(f"npm audit error: {str(e)}")
def _run_pip_audit(self, project_dir: Path, result: SecurityScanResult) -> None:
"""Run pip-audit for Python projects (if available)."""
try:
cmd = ["pip-audit", "--format", "json"]
proc = subprocess.run(
cmd,
cwd=project_dir,
capture_output=True,
text=True,
timeout=120,
)
if proc.stdout:
try:
audit_output = json.loads(proc.stdout)
for vuln in audit_output:
severity = "high" if vuln.get("fix_versions") else "medium"
result.vulnerabilities.append(
SecurityVulnerability(
severity=severity,
source="pip_audit",
title=f"Vulnerable package: {vuln.get('name')}",
description=vuln.get("description", ""),
cwe=vuln.get("aliases", [""])[0]
if vuln.get("aliases")
else None,
)
)
except json.JSONDecodeError:
pass
except FileNotFoundError:
pass # pip-audit not available
except subprocess.TimeoutExpired:
pass
except Exception:
pass
def _is_python_project(self, project_dir: Path) -> bool:
"""Check if this is a Python project."""
indicators = [
project_dir / "pyproject.toml",
project_dir / "requirements.txt",
project_dir / "setup.py",
project_dir / "setup.cfg",
]
return any(p.exists() for p in indicators)
def _check_bandit_available(self) -> bool:
"""Check if Bandit is available."""
if self._bandit_available is None:
try:
subprocess.run(
["bandit", "--version"],
capture_output=True,
timeout=5,
)
self._bandit_available = True
except (FileNotFoundError, subprocess.TimeoutExpired):
self._bandit_available = False
return self._bandit_available
def _redact_secret(self, text: str) -> str:
"""Redact a secret for safe logging."""
if len(text) <= 8:
return "*" * len(text)
return text[:4] + "*" * (len(text) - 8) + text[-4:]
def _save_results(self, spec_dir: Path, result: SecurityScanResult) -> None:
"""Save scan results to spec directory."""
spec_dir = Path(spec_dir)
spec_dir.mkdir(parents=True, exist_ok=True)
output_file = spec_dir / "security_scan_results.json"
output_data = self.to_dict(result)
with open(output_file, "w", encoding="utf-8") as f:
json.dump(output_data, f, indent=2)
def to_dict(self, result: SecurityScanResult) -> dict[str, Any]:
"""Convert result to dictionary for JSON serialization."""
return {
"secrets": result.secrets,
"vulnerabilities": [
{
"severity": v.severity,
"source": v.source,
"title": v.title,
"description": v.description,
"file": v.file,
"line": v.line,
"cwe": v.cwe,
}
for v in result.vulnerabilities
],
"scan_errors": result.scan_errors,
"has_critical_issues": result.has_critical_issues,
"should_block_qa": result.should_block_qa,
"summary": {
"total_secrets": len(result.secrets),
"total_vulnerabilities": len(result.vulnerabilities),
"critical_count": sum(
1 for v in result.vulnerabilities if v.severity == "critical"
),
"high_count": sum(
1 for v in result.vulnerabilities if v.severity == "high"
),
"medium_count": sum(
1 for v in result.vulnerabilities if v.severity == "medium"
),
"low_count": sum(
1 for v in result.vulnerabilities if v.severity == "low"
),
},
}
# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================
def scan_for_security_issues(
project_dir: Path,
spec_dir: Path | None = None,
changed_files: list[str] | None = None,
) -> SecurityScanResult:
"""
Convenience function to run security scan.
Args:
project_dir: Path to project root
spec_dir: Optional spec directory to save results
changed_files: Optional list of files to scan
Returns:
SecurityScanResult with all findings
"""
scanner = SecurityScanner()
return scanner.scan(project_dir, spec_dir, changed_files)
def has_security_issues(project_dir: Path) -> bool:
"""
Quick check if project has security issues.
Args:
project_dir: Path to project root
Returns:
True if any critical/high issues found
"""
scanner = SecurityScanner()
result = scanner.scan(project_dir, run_sast=False, run_dependency_audit=False)
return result.has_critical_issues
def scan_secrets_only(
project_dir: Path,
changed_files: list[str] | None = None,
) -> list[dict[str, Any]]:
"""
Scan only for secrets (quick scan).
Args:
project_dir: Path to project root
changed_files: Optional list of files to scan
Returns:
List of detected secrets
"""
scanner = SecurityScanner()
result = scanner.scan(
project_dir,
changed_files=changed_files,
run_sast=False,
run_dependency_audit=False,
)
return result.secrets
# =============================================================================
# CLI
# =============================================================================
def main() -> None:
"""CLI entry point for testing."""
import argparse
parser = argparse.ArgumentParser(description="Run security scans")
parser.add_argument("project_dir", type=Path, help="Path to project root")
parser.add_argument("--spec-dir", type=Path, help="Path to spec directory")
parser.add_argument(
"--secrets-only", action="store_true", help="Only scan for secrets"
)
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
scanner = SecurityScanner()
result = scanner.scan(
args.project_dir,
spec_dir=args.spec_dir,
run_sast=not args.secrets_only,
run_dependency_audit=not args.secrets_only,
)
if args.json:
print(json.dumps(scanner.to_dict(result), indent=2))
else:
print(f"Secrets Found: {len(result.secrets)}")
print(f"Vulnerabilities: {len(result.vulnerabilities)}")
print(f"Has Critical Issues: {result.has_critical_issues}")
print(f"Should Block QA: {result.should_block_qa}")
if result.secrets:
print("\nSecrets Detected:")
for secret in result.secrets:
print(f" - {secret['pattern']} in {secret['file']}:{secret['line']}")
if result.vulnerabilities:
print(f"\nVulnerabilities ({len(result.vulnerabilities)}):")
for v in result.vulnerabilities:
print(f" [{v.severity.upper()}] {v.title}")
if v.file:
print(f" File: {v.file}:{v.line or ''}")
if result.scan_errors:
print(f"\nScan Errors ({len(result.scan_errors)}):")
for error in result.scan_errors:
print(f" - {error}")
if __name__ == "__main__":
main()
+690
View File
@@ -0,0 +1,690 @@
#!/usr/bin/env python3
"""
Test Discovery Module
=====================
Detects test frameworks, test commands, and test directories in a project.
This module analyzes project configuration files to discover how tests
should be run.
The test discovery results are used by:
- QA Agent: To determine what test commands to run
- Test Creator: To know what framework to use when creating tests
- Planner: To include correct test commands in verification strategy
Usage:
from test_discovery import TestDiscovery
discovery = TestDiscovery()
result = discovery.discover(project_dir)
print(f"Test frameworks: {result['frameworks']}")
print(f"Test command: {result['test_command']}")
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class TestFramework:
"""
Represents a detected test framework.
Attributes:
name: Name of the framework (e.g., "pytest", "jest", "vitest")
type: Type of testing (unit, integration, e2e, all)
command: Command to run tests
config_file: Configuration file if found
version: Version if detected
coverage_command: Command for coverage if available
"""
__test__ = False # Prevent pytest from collecting this as a test class
name: str
type: str # unit, integration, e2e, all
command: str
config_file: str | None = None
version: str | None = None
coverage_command: str | None = None
@dataclass
class TestDiscoveryResult:
"""
Result of test framework discovery.
Attributes:
frameworks: List of detected test frameworks
test_command: Primary test command to run
test_directories: Discovered test directories
package_manager: Detected package manager
has_tests: Whether any test files were found
coverage_command: Command for coverage if available
"""
__test__ = False # Prevent pytest from collecting this as a test class
frameworks: list[TestFramework] = field(default_factory=list)
test_command: str = ""
test_directories: list[str] = field(default_factory=list)
package_manager: str = ""
has_tests: bool = False
coverage_command: str | None = None
# =============================================================================
# FRAMEWORK DETECTORS
# =============================================================================
# Pattern-based framework detection
FRAMEWORK_PATTERNS = {
# JavaScript/TypeScript
"jest": {
"config_files": [
"jest.config.js",
"jest.config.ts",
"jest.config.mjs",
"jest.config.cjs",
],
"package_key": "jest",
"type": "unit",
"command": "npx jest",
"coverage_command": "npx jest --coverage",
},
"vitest": {
"config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"],
"package_key": "vitest",
"type": "unit",
"command": "npx vitest run",
"coverage_command": "npx vitest run --coverage",
},
"mocha": {
"config_files": [
".mocharc.js",
".mocharc.json",
".mocharc.yaml",
".mocharc.yml",
],
"package_key": "mocha",
"type": "unit",
"command": "npx mocha",
"coverage_command": "npx nyc mocha",
},
"playwright": {
"config_files": ["playwright.config.js", "playwright.config.ts"],
"package_key": "@playwright/test",
"type": "e2e",
"command": "npx playwright test",
"coverage_command": None,
},
"cypress": {
"config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"],
"package_key": "cypress",
"type": "e2e",
"command": "npx cypress run",
"coverage_command": None,
},
# Python
"pytest": {
"config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"],
"pyproject_key": "pytest",
"requirements_key": "pytest",
"type": "all",
"command": "pytest",
"coverage_command": "pytest --cov",
},
"unittest": {
"config_files": [],
"type": "unit",
"command": "python -m unittest discover",
"coverage_command": "coverage run -m unittest discover",
},
# Rust
"cargo_test": {
"config_files": ["Cargo.toml"],
"type": "all",
"command": "cargo test",
"coverage_command": "cargo tarpaulin",
},
# Go
"go_test": {
"config_files": ["go.mod"],
"type": "all",
"command": "go test ./...",
"coverage_command": "go test -cover ./...",
},
# Ruby
"rspec": {
"config_files": [".rspec", "spec/spec_helper.rb"],
"gemfile_key": "rspec",
"type": "all",
"command": "bundle exec rspec",
"coverage_command": "bundle exec rspec --format documentation",
},
"minitest": {
"config_files": [],
"gemfile_key": "minitest",
"type": "unit",
"command": "bundle exec rake test",
"coverage_command": None,
},
}
# =============================================================================
# TEST DISCOVERY
# =============================================================================
class TestDiscovery:
"""
Discovers test frameworks and configurations in a project.
Analyzes:
- Package files (package.json, pyproject.toml, Cargo.toml, etc.)
- Configuration files (jest.config.js, pytest.ini, etc.)
- Directory structure (tests/, spec/, __tests__/)
"""
__test__ = False # Prevent pytest from collecting this as a test class
def __init__(self) -> None:
"""Initialize the test discovery."""
self._cache: dict[str, TestDiscoveryResult] = {}
def discover(self, project_dir: Path) -> TestDiscoveryResult:
"""
Discover test frameworks and configuration in the project.
Args:
project_dir: Path to the project root
Returns:
TestDiscoveryResult with detected frameworks and commands
"""
project_dir = Path(project_dir)
cache_key = str(project_dir.resolve())
if cache_key in self._cache:
return self._cache[cache_key]
result = TestDiscoveryResult()
# Detect package manager
result.package_manager = self._detect_package_manager(project_dir)
# Discover frameworks based on project type
if (project_dir / "package.json").exists():
self._discover_js_frameworks(project_dir, result)
# Check for Python project indicators
python_indicators = [
project_dir / "pyproject.toml",
project_dir / "requirements.txt",
project_dir / "setup.py",
project_dir / "pytest.ini",
project_dir / "conftest.py",
project_dir / "tests" / "conftest.py",
]
if any(p.exists() for p in python_indicators):
self._discover_python_frameworks(project_dir, result)
if (project_dir / "Cargo.toml").exists():
self._discover_rust_frameworks(project_dir, result)
if (project_dir / "go.mod").exists():
self._discover_go_frameworks(project_dir, result)
if (project_dir / "Gemfile").exists():
self._discover_ruby_frameworks(project_dir, result)
# Find test directories
result.test_directories = self._find_test_directories(project_dir)
# Check if tests exist
result.has_tests = self._has_test_files(project_dir, result.test_directories)
# Set primary test command
if result.frameworks:
result.test_command = result.frameworks[0].command
# Set coverage command from first framework that has one
if not result.coverage_command:
for framework in result.frameworks:
if framework.coverage_command:
result.coverage_command = framework.coverage_command
break
self._cache[cache_key] = result
return result
def _detect_package_manager(self, project_dir: Path) -> str:
"""Detect the package manager used by the project."""
if (project_dir / "pnpm-lock.yaml").exists():
return "pnpm"
if (project_dir / "yarn.lock").exists():
return "yarn"
if (project_dir / "package-lock.json").exists():
return "npm"
if (project_dir / "bun.lockb").exists():
return "bun"
if (project_dir / "uv.lock").exists():
return "uv"
if (project_dir / "poetry.lock").exists():
return "poetry"
if (project_dir / "Pipfile.lock").exists():
return "pipenv"
if (project_dir / "Cargo.lock").exists():
return "cargo"
if (project_dir / "go.sum").exists():
return "go"
if (project_dir / "Gemfile.lock").exists():
return "bundler"
return ""
def _discover_js_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover JavaScript/TypeScript test frameworks."""
package_json = project_dir / "package.json"
if not package_json.exists():
return
try:
with open(package_json, encoding="utf-8") as f:
pkg = json.load(f)
except (OSError, json.JSONDecodeError):
return
deps = pkg.get("dependencies", {})
dev_deps = pkg.get("devDependencies", {})
all_deps = {**deps, **dev_deps}
scripts = pkg.get("scripts", {})
# Check for test frameworks in dependencies
for name, pattern in FRAMEWORK_PATTERNS.items():
if "package_key" not in pattern:
continue
if pattern["package_key"] in all_deps:
# Check for config file
config_file = None
for cf in pattern.get("config_files", []):
if (project_dir / cf).exists():
config_file = cf
break
# Get version
version = all_deps.get(pattern["package_key"], "")
if version.startswith("^") or version.startswith("~"):
version = version[1:]
# Determine command - prefer npm scripts if available
command = pattern["command"]
if "test" in scripts and pattern["package_key"] in scripts.get(
"test", ""
):
command = f"{result.package_manager or 'npm'} test"
result.frameworks.append(
TestFramework(
name=name,
type=pattern["type"],
command=command,
config_file=config_file,
version=version,
coverage_command=pattern.get("coverage_command"),
)
)
# Check npm scripts for test commands
if not result.frameworks and "test" in scripts:
test_script = scripts["test"]
if (
test_script
and test_script != 'echo "Error: no test specified" && exit 1'
):
# Try to infer framework from script
framework_name = "npm_test"
framework_type = "unit"
if "jest" in test_script:
framework_name = "jest"
elif "vitest" in test_script:
framework_name = "vitest"
elif "mocha" in test_script:
framework_name = "mocha"
elif "playwright" in test_script:
framework_name = "playwright"
framework_type = "e2e"
elif "cypress" in test_script:
framework_name = "cypress"
framework_type = "e2e"
result.frameworks.append(
TestFramework(
name=framework_name,
type=framework_type,
command=f"{result.package_manager or 'npm'} test",
config_file=None,
)
)
def _discover_python_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover Python test frameworks."""
# Check for pytest.ini first (explicit pytest config)
if (project_dir / "pytest.ini").exists():
if not any(f.name == "pytest" for f in result.frameworks):
result.frameworks.append(
TestFramework(
name="pytest",
type="all",
command="pytest",
config_file="pytest.ini",
)
)
# Check pyproject.toml
pyproject = project_dir / "pyproject.toml"
if pyproject.exists():
content = pyproject.read_text()
# Check for pytest
if "pytest" in content:
if not any(f.name == "pytest" for f in result.frameworks):
config_file = (
"pyproject.toml" if "[tool.pytest" in content else None
)
result.frameworks.append(
TestFramework(
name="pytest",
type="all",
command="pytest",
config_file=config_file,
)
)
# Check requirements.txt
requirements = project_dir / "requirements.txt"
if requirements.exists():
content = requirements.read_text().lower()
if "pytest" in content and not any(
f.name == "pytest" for f in result.frameworks
):
result.frameworks.append(
TestFramework(
name="pytest",
type="all",
command="pytest",
config_file=None,
)
)
# Check for conftest.py (pytest marker)
conftest_root = project_dir / "conftest.py"
conftest_tests = project_dir / "tests" / "conftest.py"
if conftest_root.exists() or conftest_tests.exists():
if not any(f.name == "pytest" for f in result.frameworks):
result.frameworks.append(
TestFramework(
name="pytest",
type="all",
command="pytest",
config_file="conftest.py",
)
)
# Fall back to unittest if test files exist but no framework detected
if not result.frameworks:
test_dirs = self._find_test_directories(project_dir)
if test_dirs:
result.frameworks.append(
TestFramework(
name="unittest",
type="unit",
command="python -m unittest discover",
config_file=None,
)
)
def _discover_rust_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover Rust test frameworks."""
cargo_toml = project_dir / "Cargo.toml"
if cargo_toml.exists():
result.frameworks.append(
TestFramework(
name="cargo_test",
type="all",
command="cargo test",
config_file="Cargo.toml",
)
)
def _discover_go_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover Go test frameworks."""
go_mod = project_dir / "go.mod"
if go_mod.exists():
result.frameworks.append(
TestFramework(
name="go_test",
type="all",
command="go test ./...",
config_file="go.mod",
)
)
def _discover_ruby_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover Ruby test frameworks."""
gemfile = project_dir / "Gemfile"
if not gemfile.exists():
return
content = gemfile.read_text().lower()
if "rspec" in content or (project_dir / ".rspec").exists():
result.frameworks.append(
TestFramework(
name="rspec",
type="all",
command="bundle exec rspec",
config_file=".rspec" if (project_dir / ".rspec").exists() else None,
)
)
elif "minitest" in content:
result.frameworks.append(
TestFramework(
name="minitest",
type="unit",
command="bundle exec rake test",
config_file=None,
)
)
def _find_test_directories(self, project_dir: Path) -> list[str]:
"""Find test directories in the project."""
test_dir_patterns = [
"tests",
"test",
"spec",
"__tests__",
"specs",
"test_*",
]
found_dirs = []
for pattern in test_dir_patterns:
if pattern.endswith("*"):
# Glob pattern
for d in project_dir.glob(pattern):
if d.is_dir():
found_dirs.append(str(d.relative_to(project_dir)))
else:
# Exact name
test_dir = project_dir / pattern
if test_dir.is_dir():
found_dirs.append(pattern)
return found_dirs
def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool:
"""Check if any test files exist."""
test_file_patterns = [
"**/test_*.py",
"**/*_test.py",
"**/*.test.js",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/test_*.go",
"**/*_test.go",
"**/*_test.rs",
"**/spec/**/*_spec.rb",
]
# Check in test directories
for test_dir in test_directories:
test_path = project_dir / test_dir
if test_path.exists():
for pattern in test_file_patterns:
if list(test_path.glob(pattern.replace("**/", ""))):
return True
# Check project-wide
for pattern in test_file_patterns:
if list(project_dir.glob(pattern)):
return True
return False
def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]:
"""Convert result to dictionary for JSON serialization."""
return {
"frameworks": [
{
"name": f.name,
"type": f.type,
"command": f.command,
"config_file": f.config_file,
"version": f.version,
"coverage_command": f.coverage_command,
}
for f in result.frameworks
],
"test_command": result.test_command,
"test_directories": result.test_directories,
"package_manager": result.package_manager,
"has_tests": result.has_tests,
"coverage_command": result.coverage_command,
}
def clear_cache(self) -> None:
"""Clear the internal cache."""
self._cache.clear()
# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================
def discover_tests(project_dir: Path) -> TestDiscoveryResult:
"""
Convenience function to discover tests in a project.
Args:
project_dir: Path to project root
Returns:
TestDiscoveryResult with detected frameworks
"""
discovery = TestDiscovery()
return discovery.discover(project_dir)
def get_test_command(project_dir: Path) -> str:
"""
Get the primary test command for a project.
Args:
project_dir: Path to project root
Returns:
Test command string, or empty string if not found
"""
discovery = TestDiscovery()
result = discovery.discover(project_dir)
return result.test_command
def get_test_frameworks(project_dir: Path) -> list[str]:
"""
Get list of test framework names in a project.
Args:
project_dir: Path to project root
Returns:
List of framework names
"""
discovery = TestDiscovery()
result = discovery.discover(project_dir)
return [f.name for f in result.frameworks]
# =============================================================================
# CLI
# =============================================================================
def main() -> None:
"""CLI entry point for testing."""
import argparse
parser = argparse.ArgumentParser(description="Discover test frameworks")
parser.add_argument("project_dir", type=Path, help="Path to project root")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
discovery = TestDiscovery()
result = discovery.discover(args.project_dir)
if args.json:
print(json.dumps(discovery.to_dict(result), indent=2))
else:
print(f"Package Manager: {result.package_manager or 'unknown'}")
print(f"Has Tests: {result.has_tests}")
print(f"Test Command: {result.test_command or 'none'}")
print(f"Test Directories: {', '.join(result.test_directories) or 'none'}")
print(f"Coverage Command: {result.coverage_command or 'none'}")
print(f"\nFrameworks ({len(result.frameworks)}):")
for f in result.frameworks:
print(f" - {f.name} ({f.type})")
print(f" Command: {f.command}")
if f.config_file:
print(f" Config: {f.config_file}")
if f.version:
print(f" Version: {f.version}")
if __name__ == "__main__":
main()
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""
Analyzer facade module.
Provides backward compatibility for scripts that import from analyzer.py at the root.
Actual implementation is in analysis/analyzer.py.
"""
from analysis.analyzer import (
ProjectAnalyzer,
ServiceAnalyzer,
analyze_project,
analyze_service,
main,
)
__all__ = [
"ServiceAnalyzer",
"ProjectAnalyzer",
"analyze_project",
"analyze_service",
"main",
]
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
"""
Auto Claude tools module facade.
Provides MCP tools for agent operations.
Re-exports from agents.tools_pkg for clean imports.
"""
from agents.tools_pkg.models import ( # noqa: F401
ELECTRON_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,
)
from agents.tools_pkg.permissions import get_allowed_tools # noqa: F401
from agents.tools_pkg.registry import ( # noqa: F401
create_auto_claude_mcp_server,
is_tools_available,
)
__all__ = [
"create_auto_claude_mcp_server",
"get_allowed_tools",
"is_tools_available",
"TOOL_UPDATE_SUBTASK_STATUS",
"TOOL_GET_BUILD_PROGRESS",
"TOOL_RECORD_DISCOVERY",
"TOOL_RECORD_GOTCHA",
"TOOL_GET_SESSION_CONTEXT",
"TOOL_UPDATE_QA_STATUS",
"ELECTRON_TOOLS",
"is_electron_mcp_enabled",
]
+21
View File
@@ -0,0 +1,21 @@
"""Backward compatibility shim - import from analysis.ci_discovery instead."""
from analysis.ci_discovery import (
HAS_YAML,
CIConfig,
CIDiscovery,
CIWorkflow,
discover_ci,
get_ci_system,
get_ci_test_commands,
)
__all__ = [
"CIConfig",
"CIWorkflow",
"CIDiscovery",
"discover_ci",
"get_ci_test_commands",
"get_ci_system",
"HAS_YAML",
]
+18
View File
@@ -0,0 +1,18 @@
"""
Auto Claude CLI Package
=======================
Command-line interface for the Auto Claude autonomous coding framework.
This package provides a modular CLI structure:
- main.py: Argument parsing and command routing
- spec_commands.py: Spec listing and management
- build_commands.py: Build execution and follow-up tasks
- workspace_commands.py: Workspace management (merge, review, discard)
- qa_commands.py: QA validation commands
- utils.py: Shared utilities and configuration
"""
from .main import main
__all__ = ["main"]
+216
View File
@@ -0,0 +1,216 @@
"""
Batch Task Management Commands
==============================
Commands for creating and managing multiple tasks from batch files.
"""
import json
from pathlib import Path
from ui import highlight, print_status
def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
"""
Create multiple tasks from a batch JSON file.
Args:
batch_file: Path to JSON file with task definitions
project_dir: Project directory
Returns:
True if successful
"""
batch_path = Path(batch_file)
if not batch_path.exists():
print_status(f"Batch file not found: {batch_file}", "error")
return False
try:
with open(batch_path) as f:
batch_data = json.load(f)
except json.JSONDecodeError as e:
print_status(f"Invalid JSON in batch file: {e}", "error")
return False
tasks = batch_data.get("tasks", [])
if not tasks:
print_status("No tasks found in batch file", "warning")
return False
print_status(f"Creating {len(tasks)} tasks from batch file", "info")
print()
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
specs_dir.mkdir(parents=True, exist_ok=True)
# Find next spec ID
existing_specs = [d.name for d in specs_dir.iterdir() if d.is_dir()]
next_id = (
max([int(s.split("-")[0]) for s in existing_specs if s[0].isdigit()] or [0]) + 1
)
created_specs = []
for idx, task in enumerate(tasks, 1):
spec_id = f"{next_id:03d}"
task_title = task.get("title", f"Task {idx}")
task_slug = task_title.lower().replace(" ", "-")[:50]
spec_name = f"{spec_id}-{task_slug}"
spec_dir = specs_dir / spec_name
spec_dir.mkdir(exist_ok=True)
# Create requirements.json
requirements = {
"task_description": task.get("description", task_title),
"description": task.get("description", task_title),
"workflow_type": task.get("workflow_type", "feature"),
"services_involved": task.get("services", ["frontend"]),
"priority": task.get("priority", 5),
"complexity_inferred": task.get("complexity", "standard"),
"inferred_from": {},
"created_at": Path(spec_dir).stat().st_mtime,
"estimate": {
"estimated_hours": task.get("estimated_hours", 4.0),
"estimated_days": task.get("estimated_days", 0.5),
},
}
req_file = spec_dir / "requirements.json"
with open(req_file, "w") as f:
json.dump(requirements, f, indent=2, default=str)
created_specs.append(
{
"id": spec_id,
"name": spec_name,
"title": task_title,
"status": "pending_spec_creation",
}
)
print_status(
f"[{idx}/{len(tasks)}] Created {spec_id} - {task_title}", "success"
)
next_id += 1
print()
print_status(f"Created {len(created_specs)} spec(s) successfully", "success")
print()
# Show summary
print(highlight("Next steps:"))
print(" 1. Generate specs: spec_runner.py --continue <spec_id>")
print(" 2. Approve specs and build them")
print(" 3. Run: python run.py --spec <id> to execute")
return True
def handle_batch_status_command(project_dir: str) -> bool:
"""
Show status of all specs in project.
Args:
project_dir: Project directory
Returns:
True if successful
"""
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
if not specs_dir.exists():
print_status("No specs found in project", "warning")
return True
specs = sorted([d for d in specs_dir.iterdir() if d.is_dir()])
if not specs:
print_status("No specs found", "warning")
return True
print_status(f"Found {len(specs)} spec(s)", "info")
print()
for spec_dir in specs:
spec_name = spec_dir.name
req_file = spec_dir / "requirements.json"
status = "unknown"
title = spec_name
if req_file.exists():
try:
with open(req_file) as f:
req = json.load(f)
title = req.get("task_description", title)
except json.JSONDecodeError:
pass
# Determine status
if (spec_dir / "spec.md").exists():
status = "spec_created"
elif (spec_dir / "implementation_plan.json").exists():
status = "building"
elif (spec_dir / "qa_report.md").exists():
status = "qa_approved"
else:
status = "pending_spec"
status_icon = {
"pending_spec": "",
"spec_created": "📋",
"building": "⚙️",
"qa_approved": "",
"unknown": "",
}.get(status, "")
print(f"{status_icon} {spec_name:<40} {title}")
return True
def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool:
"""
Clean up completed specs and worktrees.
Args:
project_dir: Project directory
dry_run: If True, show what would be deleted
Returns:
True if successful
"""
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
worktrees_dir = Path(project_dir) / ".worktrees"
if not specs_dir.exists():
print_status("No specs directory found", "info")
return True
# Find completed specs
completed = []
for spec_dir in specs_dir.iterdir():
if spec_dir.is_dir() and (spec_dir / "qa_report.md").exists():
completed.append(spec_dir.name)
if not completed:
print_status("No completed specs to clean up", "info")
return True
print_status(f"Found {len(completed)} completed spec(s)", "info")
if dry_run:
print()
print("Would remove:")
for spec_name in completed:
print(f" - {spec_name}")
wt_path = worktrees_dir / spec_name
if wt_path.exists():
print(f" └─ .worktrees/{spec_name}/")
print()
print("Run with --no-dry-run to actually delete")
return True
+471
View File
@@ -0,0 +1,471 @@
"""
Build Commands
==============
CLI commands for building specs and handling the main build flow.
"""
import asyncio
import sys
from pathlib import Path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
# Import only what we need at module level
# Heavy imports are lazy-loaded in functions to avoid import errors
from progress import print_paused_banner
from review import ReviewState
from ui import (
BuildState,
Icons,
MenuOption,
StatusManager,
bold,
box,
highlight,
icon,
muted,
print_status,
select_menu,
success,
warning,
)
from workspace import (
WorkspaceMode,
check_existing_build,
choose_workspace,
finalize_workspace,
get_existing_build_worktree,
handle_workspace_choice,
setup_workspace,
)
from .input_handlers import (
read_from_file,
read_multiline_input,
)
def handle_build_command(
project_dir: Path,
spec_dir: Path,
model: str,
max_iterations: int | None,
verbose: bool,
force_isolated: bool,
force_direct: bool,
auto_continue: bool,
skip_qa: bool,
force_bypass_approval: bool,
base_branch: str | None = None,
) -> None:
"""
Handle the main build command.
Args:
project_dir: Project root directory
spec_dir: Spec directory path
model: Model to use (used as default; may be overridden by task_metadata.json)
max_iterations: Maximum number of iterations (None for unlimited)
verbose: Enable verbose output
force_isolated: Force isolated workspace mode
force_direct: Force direct workspace mode
auto_continue: Auto-continue mode (non-interactive)
skip_qa: Skip automatic QA validation
force_bypass_approval: Force bypass approval check
base_branch: Base branch for worktree creation (default: current branch)
"""
# Lazy imports to avoid loading heavy modules
from agent import run_autonomous_agent, sync_plan_to_source
from debug import (
debug,
debug_info,
debug_section,
debug_success,
)
from phase_config import get_phase_model
from qa_loop import run_qa_validation_loop, should_run_qa
from .utils import print_banner, validate_environment
# Get the resolved model for the planning phase (first phase of build)
# This respects task_metadata.json phase configuration from the UI
planning_model = get_phase_model(spec_dir, "planning", model)
coding_model = get_phase_model(spec_dir, "coding", model)
qa_model = get_phase_model(spec_dir, "qa", model)
print_banner()
print(f"\nProject directory: {project_dir}")
print(f"Spec: {spec_dir.name}")
# Show phase-specific models if they differ
if planning_model != coding_model or coding_model != qa_model:
print(
f"Models: Planning={planning_model.split('-')[1] if '-' in planning_model else planning_model}, "
f"Coding={coding_model.split('-')[1] if '-' in coding_model else coding_model}, "
f"QA={qa_model.split('-')[1] if '-' in qa_model else qa_model}"
)
else:
print(f"Model: {planning_model}")
if max_iterations:
print(f"Max iterations: {max_iterations}")
else:
print("Max iterations: Unlimited (runs until all subtasks complete)")
print()
# Validate environment
if not validate_environment(spec_dir):
sys.exit(1)
# Check human review approval
review_state = ReviewState.load(spec_dir)
if not review_state.is_approval_valid(spec_dir):
if force_bypass_approval:
# User explicitly bypassed approval check
print()
print(
warning(
f"{icon(Icons.WARNING)} WARNING: Bypassing approval check with --force"
)
)
print(muted("This spec has not been approved for building."))
print()
else:
print()
content = [
bold(f"{icon(Icons.WARNING)} BUILD BLOCKED - REVIEW REQUIRED"),
"",
"This spec requires human approval before building.",
]
if review_state.approved and not review_state.is_approval_valid(spec_dir):
# Spec changed after approval
content.append("")
content.append(warning("The spec has been modified since approval."))
content.append("Please re-review and re-approve.")
content.extend(
[
"",
highlight("To review and approve:"),
f" python auto-claude/review.py --spec-dir {spec_dir}",
"",
muted("Or use --force to bypass this check (not recommended)."),
]
)
print(box(content, width=70, style="heavy"))
print()
sys.exit(1)
else:
debug_success(
"run.py", "Review approval validated", approved_by=review_state.approved_by
)
# Check for existing build
if get_existing_build_worktree(project_dir, spec_dir.name):
if auto_continue:
# Non-interactive mode: auto-continue with existing build
debug("run.py", "Auto-continue mode: continuing with existing build")
print("Auto-continue: Resuming existing build...")
else:
continue_existing = check_existing_build(project_dir, spec_dir.name)
if continue_existing:
# Continue with existing worktree
pass
else:
# User chose to start fresh or merged existing
pass
# Choose workspace (skip for parallel mode - it always uses worktrees)
working_dir = project_dir
worktree_manager = None
source_spec_dir = None # Track original spec dir for syncing back from worktree
# Let user choose workspace mode (or auto-select if --auto-continue)
workspace_mode = choose_workspace(
project_dir,
spec_dir.name,
force_isolated=force_isolated,
force_direct=force_direct,
auto_continue=auto_continue,
)
if workspace_mode == WorkspaceMode.ISOLATED:
# Keep reference to original spec directory for syncing progress back
source_spec_dir = spec_dir
working_dir, worktree_manager, localized_spec_dir = setup_workspace(
project_dir,
spec_dir.name,
workspace_mode,
source_spec_dir=spec_dir,
base_branch=base_branch,
)
# Use the localized spec directory (inside worktree) for AI access
if localized_spec_dir:
spec_dir = localized_spec_dir
# Run the autonomous agent
debug_section("run.py", "Starting Build Execution")
debug(
"run.py",
"Build configuration",
model=model,
workspace_mode=str(workspace_mode),
working_dir=str(working_dir),
spec_dir=str(spec_dir),
)
try:
debug("run.py", "Starting agent execution")
asyncio.run(
run_autonomous_agent(
project_dir=working_dir, # Use worktree if isolated
spec_dir=spec_dir,
model=model,
max_iterations=max_iterations,
verbose=verbose,
source_spec_dir=source_spec_dir, # For syncing progress back to main project
)
)
debug_success("run.py", "Agent execution completed")
# Run QA validation BEFORE finalization (while worktree still exists)
# QA must sign off before the build is considered complete
qa_approved = True # Default to approved if QA is skipped
if not skip_qa and should_run_qa(spec_dir):
print("\n" + "=" * 70)
print(" SUBTASKS COMPLETE - STARTING QA VALIDATION")
print("=" * 70)
print("\nAll subtasks completed. Now running QA validation loop...")
print("This ensures production-quality output before sign-off.\n")
try:
qa_approved = asyncio.run(
run_qa_validation_loop(
project_dir=working_dir,
spec_dir=spec_dir,
model=model,
verbose=verbose,
)
)
if qa_approved:
print("\n" + "=" * 70)
print(" ✅ QA VALIDATION PASSED")
print("=" * 70)
print("\nAll acceptance criteria verified.")
print("The implementation is production-ready.\n")
else:
print("\n" + "=" * 70)
print(" ⚠️ QA VALIDATION INCOMPLETE")
print("=" * 70)
print("\nSome issues require manual attention.")
print(f"See: {spec_dir / 'qa_report.md'}")
print(f"Or: {spec_dir / 'QA_FIX_REQUEST.md'}")
print(
f"\nResume QA: python auto-claude/run.py --spec {spec_dir.name} --qa\n"
)
# Sync implementation plan to main project after QA
# This ensures the main project has the latest status (human_review)
if sync_plan_to_source(spec_dir, source_spec_dir):
debug_info(
"run.py", "Implementation plan synced to main project after QA"
)
except KeyboardInterrupt:
print("\n\nQA validation paused.")
print(f"Resume: python auto-claude/run.py --spec {spec_dir.name} --qa")
qa_approved = False
# Post-build finalization (only for isolated sequential mode)
# This happens AFTER QA validation so the worktree still exists
if worktree_manager:
choice = finalize_workspace(
project_dir,
spec_dir.name,
worktree_manager,
auto_continue=auto_continue,
)
handle_workspace_choice(
choice, project_dir, spec_dir.name, worktree_manager
)
except KeyboardInterrupt:
_handle_build_interrupt(
spec_dir=spec_dir,
project_dir=project_dir,
worktree_manager=worktree_manager,
working_dir=working_dir,
model=model,
max_iterations=max_iterations,
verbose=verbose,
)
except Exception as e:
print(f"\nFatal error: {e}")
if verbose:
import traceback
traceback.print_exc()
sys.exit(1)
def _handle_build_interrupt(
spec_dir: Path,
project_dir: Path,
worktree_manager,
working_dir: Path,
model: str,
max_iterations: int | None,
verbose: bool,
) -> None:
"""
Handle keyboard interrupt during build.
Args:
spec_dir: Spec directory path
project_dir: Project root directory
worktree_manager: Worktree manager instance (if using isolated mode)
working_dir: Current working directory
model: Model being used
max_iterations: Maximum iterations
verbose: Verbose mode flag
"""
from agent import run_autonomous_agent
# Print paused banner
print_paused_banner(spec_dir, spec_dir.name, has_worktree=bool(worktree_manager))
# Update status file
status_manager = StatusManager(project_dir)
status_manager.update(state=BuildState.PAUSED)
# Offer to add human input with enhanced menu
try:
options = [
MenuOption(
key="type",
label="Type instructions",
icon=Icons.EDIT,
description="Enter guidance for the agent's next session",
),
MenuOption(
key="paste",
label="Paste from clipboard",
icon=Icons.CLIPBOARD,
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
),
MenuOption(
key="file",
label="Read from file",
icon=Icons.DOCUMENT,
description="Load instructions from a text file",
),
MenuOption(
key="skip",
label="Continue without instructions",
icon=Icons.SKIP,
description="Resume the build as-is",
),
MenuOption(
key="quit",
label="Quit",
icon=Icons.DOOR,
description="Exit without resuming",
),
]
choice = select_menu(
title="What would you like to do?",
options=options,
subtitle="Progress saved. You can add instructions for the agent.",
allow_quit=False, # We have explicit quit option
)
if choice == "quit" or choice is None:
print()
print_status("Exiting...", "info")
status_manager.set_inactive()
sys.exit(0)
human_input = ""
if choice == "file":
# Read from file
human_input = read_from_file()
if human_input is None:
human_input = ""
elif choice in ["type", "paste"]:
human_input = read_multiline_input("Enter/paste your instructions below.")
if human_input is None:
print()
print_status("Exiting without saving instructions...", "warning")
status_manager.set_inactive()
sys.exit(0)
if human_input:
# Save to HUMAN_INPUT.md
input_file = spec_dir / "HUMAN_INPUT.md"
input_file.write_text(human_input)
content = [
success(f"{icon(Icons.SUCCESS)} INSTRUCTIONS SAVED"),
"",
f"Saved to: {highlight(str(input_file.name))}",
"",
muted(
"The agent will read and follow these instructions when you resume."
),
]
print()
print(box(content, width=70, style="heavy"))
elif choice != "skip":
print()
print_status("No instructions provided.", "info")
# If 'skip' was selected, actually resume the build
if choice == "skip":
print()
print_status("Resuming build...", "info")
status_manager.update(state=BuildState.RUNNING)
asyncio.run(
run_autonomous_agent(
project_dir=working_dir,
spec_dir=spec_dir,
model=model,
max_iterations=max_iterations,
verbose=verbose,
)
)
# Build completed or was interrupted again - exit
sys.exit(0)
except KeyboardInterrupt:
# User pressed Ctrl+C again during input prompt - exit immediately
print()
print_status("Exiting...", "warning")
status_manager = StatusManager(project_dir)
status_manager.set_inactive()
sys.exit(0)
except EOFError:
# stdin closed
pass
# Resume instructions (shown when user provided instructions or chose file/type/paste)
print()
content = [
bold(f"{icon(Icons.PLAY)} TO RESUME"),
"",
f"Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
]
if worktree_manager:
content.append("")
content.append(muted("Your build is in a separate workspace and is safe."))
print(box(content, width=70, style="light"))
print()
+375
View File
@@ -0,0 +1,375 @@
"""
Followup Commands
=================
CLI commands for adding follow-up tasks to completed specs.
"""
import asyncio
import json
import sys
from pathlib import Path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from progress import count_subtasks, is_build_complete
from ui import (
Icons,
MenuOption,
bold,
box,
error,
highlight,
icon,
muted,
print_status,
select_menu,
success,
warning,
)
def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
"""
Collect a follow-up task description from the user.
Provides multiple input methods (type, paste, file) similar to the
HUMAN_INPUT.md pattern used during build interrupts. Includes retry
logic for empty input.
Args:
spec_dir: The spec directory where FOLLOWUP_REQUEST.md will be saved
max_retries: Maximum number of times to prompt on empty input (default: 3)
Returns:
The collected task description, or None if cancelled
"""
retry_count = 0
while retry_count < max_retries:
# Present options menu
options = [
MenuOption(
key="type",
label="Type follow-up task",
icon=Icons.EDIT,
description="Enter a description of additional work needed",
),
MenuOption(
key="paste",
label="Paste from clipboard",
icon=Icons.CLIPBOARD,
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
),
MenuOption(
key="file",
label="Read from file",
icon=Icons.DOCUMENT,
description="Load task description from a text file",
),
MenuOption(
key="quit",
label="Cancel",
icon=Icons.DOOR,
description="Exit without adding follow-up",
),
]
# Show retry message if this is a retry
subtitle = "Describe the additional work you want to add to this spec."
if retry_count > 0:
subtitle = warning(
f"Empty input received. Please try again. ({max_retries - retry_count} attempts remaining)"
)
choice = select_menu(
title="How would you like to provide your follow-up task?",
options=options,
subtitle=subtitle,
allow_quit=False, # We have explicit quit option
)
if choice == "quit" or choice is None:
return None
followup_task = ""
if choice == "file":
# Read from file
print()
print(
f"{icon(Icons.DOCUMENT)} Enter the path to your task description file:"
)
try:
file_path_str = input(f" {icon(Icons.POINTER)} ").strip()
except (KeyboardInterrupt, EOFError):
print()
print_status("Cancelled.", "warning")
return None
# Handle empty file path
if not file_path_str:
print()
print_status("No file path provided.", "warning")
retry_count += 1
continue
try:
# Expand ~ and resolve path
file_path = Path(file_path_str).expanduser().resolve()
if file_path.exists():
followup_task = file_path.read_text().strip()
if followup_task:
print_status(
f"Loaded {len(followup_task)} characters from file",
"success",
)
else:
print()
print_status(
"File is empty. Please provide a file with task description.",
"error",
)
retry_count += 1
continue
else:
print_status(f"File not found: {file_path}", "error")
print(
muted(" Check that the path is correct and the file exists.")
)
retry_count += 1
continue
except PermissionError:
print_status(f"Permission denied: cannot read {file_path_str}", "error")
print(muted(" Check file permissions and try again."))
retry_count += 1
continue
except Exception as e:
print_status(f"Error reading file: {e}", "error")
retry_count += 1
continue
elif choice in ["type", "paste"]:
print()
content = [
"Enter/paste your follow-up task description below.",
"",
muted("Describe what additional work you want to add."),
muted("The planner will create new subtasks based on this."),
"",
muted("Press Enter on an empty line when done."),
]
print(box(content, width=60, style="light"))
print()
lines = []
empty_count = 0
while True:
try:
line = input()
if line == "":
empty_count += 1
if empty_count >= 1: # Stop on first empty line
break
else:
empty_count = 0
lines.append(line)
except KeyboardInterrupt:
print()
print_status("Cancelled.", "warning")
return None
except EOFError:
break
followup_task = "\n".join(lines).strip()
# Validate that we have content
if not followup_task:
print()
print_status("No task description provided.", "warning")
retry_count += 1
continue
# Save to FOLLOWUP_REQUEST.md
request_file = spec_dir / "FOLLOWUP_REQUEST.md"
request_file.write_text(followup_task)
# Show confirmation
content = [
success(f"{icon(Icons.SUCCESS)} FOLLOW-UP TASK SAVED"),
"",
f"Saved to: {highlight(str(request_file.name))}",
"",
muted("The planner will create new subtasks based on this task."),
]
print()
print(box(content, width=70, style="heavy"))
return followup_task
# Max retries exceeded
print()
print_status("Maximum retry attempts reached. Follow-up cancelled.", "error")
return None
def handle_followup_command(
project_dir: Path,
spec_dir: Path,
model: str,
verbose: bool = False,
) -> None:
"""
Handle the --followup command.
Args:
project_dir: Project root directory
spec_dir: Spec directory path
model: Model to use
verbose: Enable verbose output
"""
# Lazy imports to avoid loading heavy modules
from agent import run_followup_planner
from .utils import print_banner, validate_environment
print_banner()
print(f"\nFollow-up request for: {spec_dir.name}")
# Check if implementation_plan.json exists
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
print()
print(error(f"{icon(Icons.ERROR)} No implementation plan found."))
print()
content = [
"This spec has not been built yet.",
"",
"Follow-up tasks can only be added to specs that have been",
"built at least once. Run a regular build first:",
"",
highlight(f" python auto-claude/run.py --spec {spec_dir.name}"),
"",
muted("After the build completes, you can add follow-up tasks."),
]
print(box(content, width=70, style="light"))
sys.exit(1)
# Check if build is complete
if not is_build_complete(spec_dir):
completed, total = count_subtasks(spec_dir)
pending = total - completed
print()
print(
error(
f"{icon(Icons.ERROR)} Build not complete ({completed}/{total} subtasks)."
)
)
print()
content = [
f"There are still {pending} pending subtask(s) to complete.",
"",
"Follow-up tasks can only be added after all current subtasks",
"are finished. Complete the current build first:",
"",
highlight(f" python auto-claude/run.py --spec {spec_dir.name}"),
"",
muted("The build will continue from where it left off."),
]
print(box(content, width=70, style="light"))
sys.exit(1)
# Check for prior follow-ups (for sequential follow-up context)
prior_followup_count = 0
try:
with open(plan_file) as f:
plan_data = json.load(f)
phases = plan_data.get("phases", [])
# Count phases that look like follow-up phases (name contains "Follow" or high phase number)
for phase in phases:
phase_name = phase.get("name", "")
if "follow" in phase_name.lower() or "followup" in phase_name.lower():
prior_followup_count += 1
except (json.JSONDecodeError, KeyError):
pass # If plan parsing fails, just continue without prior count
# Build is complete - proceed to follow-up workflow
print()
if prior_followup_count > 0:
print(
success(
f"{icon(Icons.SUCCESS)} Build is complete ({prior_followup_count} prior follow-up(s)). Ready for more follow-up tasks."
)
)
else:
print(
success(
f"{icon(Icons.SUCCESS)} Build is complete. Ready for follow-up tasks."
)
)
# Collect follow-up task from user
followup_task = collect_followup_task(spec_dir)
if followup_task is None:
# User cancelled
print()
print_status("Follow-up cancelled.", "info")
return
# Successfully collected follow-up task
# The collect_followup_task() function already saved to FOLLOWUP_REQUEST.md
# Now run the follow-up planner to add new subtasks
print()
if not validate_environment(spec_dir):
sys.exit(1)
try:
success_result = asyncio.run(
run_followup_planner(
project_dir=project_dir,
spec_dir=spec_dir,
model=model,
verbose=verbose,
)
)
if success_result:
# Show next steps after successful planning
content = [
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
"",
"New subtasks have been added to your implementation plan.",
"",
highlight("To continue building:"),
f" python auto-claude/run.py --spec {spec_dir.name}",
]
print(box(content, width=70, style="heavy"))
else:
# Planning didn't fully succeed
content = [
bold(f"{icon(Icons.WARNING)} FOLLOW-UP PLANNING INCOMPLETE"),
"",
"Check the implementation plan manually.",
"",
muted("You may need to run the follow-up again."),
]
print(box(content, width=70, style="light"))
sys.exit(1)
except KeyboardInterrupt:
print("\n\nFollow-up planning paused.")
print(f"To retry: python auto-claude/run.py --spec {spec_dir.name} --followup")
sys.exit(0)
except Exception as e:
print()
print(error(f"{icon(Icons.ERROR)} Follow-up planning error: {e}"))
if verbose:
import traceback
traceback.print_exc()
sys.exit(1)
+210
View File
@@ -0,0 +1,210 @@
"""
Input Handlers
==============
Reusable user input collection utilities for CLI commands.
"""
import sys
from pathlib import Path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from ui import (
Icons,
MenuOption,
box,
icon,
muted,
print_status,
select_menu,
)
def collect_user_input_interactive(
title: str,
subtitle: str,
prompt_text: str,
allow_file: bool = True,
allow_paste: bool = True,
) -> str | None:
"""
Collect user input through an interactive menu.
Provides multiple input methods:
- Type directly
- Paste from clipboard
- Read from file (optional)
Args:
title: Menu title
subtitle: Menu subtitle
prompt_text: Text to display in the input box
allow_file: Whether to allow file input (default: True)
allow_paste: Whether to allow paste option (default: True)
Returns:
The collected input string, or None if cancelled
"""
# Build options list
options = [
MenuOption(
key="type",
label="Type instructions",
icon=Icons.EDIT,
description="Enter text directly",
),
]
if allow_paste:
options.append(
MenuOption(
key="paste",
label="Paste from clipboard",
icon=Icons.CLIPBOARD,
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
)
)
if allow_file:
options.append(
MenuOption(
key="file",
label="Read from file",
icon=Icons.DOCUMENT,
description="Load text from a file",
)
)
options.extend(
[
MenuOption(
key="skip",
label="Continue without input",
icon=Icons.SKIP,
description="Skip this step",
),
MenuOption(
key="quit",
label="Quit",
icon=Icons.DOOR,
description="Exit",
),
]
)
choice = select_menu(
title=title,
options=options,
subtitle=subtitle,
allow_quit=False, # We have explicit quit option
)
if choice == "quit" or choice is None:
return None
if choice == "skip":
return ""
user_input = ""
if choice == "file":
# Read from file
user_input = read_from_file()
if user_input is None:
return None
elif choice in ["type", "paste"]:
user_input = read_multiline_input(prompt_text)
if user_input is None:
return None
return user_input
def read_from_file() -> str | None:
"""
Read text content from a file path provided by the user.
Returns:
File contents as string, or None if cancelled/error
"""
print()
print(f"{icon(Icons.DOCUMENT)} Enter the path to your file:")
try:
file_path_input = input(f" {icon(Icons.POINTER)} ").strip()
except (KeyboardInterrupt, EOFError):
print()
print_status("Cancelled.", "warning")
return None
if not file_path_input:
print_status("No file path provided.", "warning")
return None
try:
# Expand ~ and resolve path
file_path = Path(file_path_input).expanduser().resolve()
if file_path.exists():
content = file_path.read_text().strip()
if content:
print_status(
f"Loaded {len(content)} characters from file",
"success",
)
return content
else:
print_status("File is empty.", "error")
return None
else:
print_status(f"File not found: {file_path}", "error")
return None
except PermissionError:
print_status(f"Permission denied: cannot read {file_path_input}", "error")
return None
except Exception as e:
print_status(f"Error reading file: {e}", "error")
return None
def read_multiline_input(prompt_text: str) -> str | None:
"""
Read multi-line input from the user.
Args:
prompt_text: Text to display in the prompt box
Returns:
User input as string, or None if cancelled
"""
print()
content = [
prompt_text,
muted("Press Enter on an empty line when done."),
]
print(box(content, width=60, style="light"))
print()
lines = []
empty_count = 0
while True:
try:
line = input()
if line == "":
empty_count += 1
if empty_count >= 1: # Stop on first empty line
break
else:
empty_count = 0
lines.append(line)
except KeyboardInterrupt:
print()
print_status("Cancelled.", "warning")
return None
except EOFError:
break
return "\n".join(lines).strip()
+412
View File
@@ -0,0 +1,412 @@
"""
Auto Claude CLI - Main Entry Point
===================================
Command-line interface for the Auto Claude autonomous coding framework.
"""
import argparse
import os
import sys
from pathlib import Path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from .batch_commands import (
handle_batch_cleanup_command,
handle_batch_create_command,
handle_batch_status_command,
)
from .build_commands import handle_build_command
from .followup_commands import handle_followup_command
from .qa_commands import (
handle_qa_command,
handle_qa_status_command,
handle_review_status_command,
)
from .spec_commands import print_specs_list
from .utils import (
DEFAULT_MODEL,
find_spec,
get_project_dir,
print_banner,
setup_environment,
)
from .workspace_commands import (
handle_cleanup_worktrees_command,
handle_discard_command,
handle_list_worktrees_command,
handle_merge_command,
handle_review_command,
)
def parse_args() -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Auto Claude Framework - Autonomous multi-session coding agent",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# List all specs
python auto-claude/run.py --list
# Run a specific spec (by number or full name)
python auto-claude/run.py --spec 001
python auto-claude/run.py --spec 001-initial-app
# Workspace management (after build completes)
python auto-claude/run.py --spec 001 --merge # Add build to your project
python auto-claude/run.py --spec 001 --review # See what was built
python auto-claude/run.py --spec 001 --discard # Delete build (with confirmation)
# Advanced options
python auto-claude/run.py --spec 001 --direct # Skip workspace isolation
python auto-claude/run.py --spec 001 --isolated # Force workspace isolation
# Status checks
python auto-claude/run.py --spec 001 --review-status # Check human review status
python auto-claude/run.py --spec 001 --qa-status # Check QA validation status
Prerequisites:
1. Create a spec first: claude /spec
2. Run 'claude setup-token' and set CLAUDE_CODE_OAUTH_TOKEN
Environment Variables:
CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (required)
Get it by running: claude setup-token
AUTO_BUILD_MODEL Override default model (optional)
""",
)
parser.add_argument(
"--list",
action="store_true",
help="List all available specs and their status",
)
parser.add_argument(
"--spec",
type=str,
default=None,
help="Spec to run (e.g., '001' or '001-feature-name')",
)
parser.add_argument(
"--project-dir",
type=Path,
default=None,
help="Project directory (default: current working directory)",
)
parser.add_argument(
"--max-iterations",
type=int,
default=None,
help="Maximum number of agent sessions (default: unlimited)",
)
parser.add_argument(
"--model",
type=str,
default=None,
help=f"Claude model to use (default: {DEFAULT_MODEL})",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable verbose output",
)
# Workspace options
workspace_group = parser.add_mutually_exclusive_group()
workspace_group.add_argument(
"--isolated",
action="store_true",
help="Force building in isolated workspace (safer)",
)
workspace_group.add_argument(
"--direct",
action="store_true",
help="Build directly in your project (no isolation)",
)
# Build management commands
build_group = parser.add_mutually_exclusive_group()
build_group.add_argument(
"--merge",
action="store_true",
help="Merge an existing build into your project",
)
build_group.add_argument(
"--review",
action="store_true",
help="Review what an existing build contains",
)
build_group.add_argument(
"--discard",
action="store_true",
help="Discard an existing build (requires confirmation)",
)
# Merge options
parser.add_argument(
"--no-commit",
action="store_true",
help="With --merge: stage changes but don't commit (review in IDE first)",
)
parser.add_argument(
"--merge-preview",
action="store_true",
help="Preview merge conflicts without actually merging (returns JSON)",
)
# QA options
parser.add_argument(
"--qa",
action="store_true",
help="Run QA validation loop on a completed build",
)
parser.add_argument(
"--qa-status",
action="store_true",
help="Show QA validation status for a spec",
)
parser.add_argument(
"--skip-qa",
action="store_true",
help="Skip automatic QA validation after build completes",
)
# Follow-up options
parser.add_argument(
"--followup",
action="store_true",
help="Add follow-up tasks to a completed spec (extends existing implementation plan)",
)
# Review options
parser.add_argument(
"--review-status",
action="store_true",
help="Show human review/approval status for a spec",
)
# Non-interactive mode (for UI/automation)
parser.add_argument(
"--auto-continue",
action="store_true",
help="Non-interactive mode: auto-continue existing builds, skip prompts (for UI integration)",
)
# Worktree management
parser.add_argument(
"--list-worktrees",
action="store_true",
help="List all spec worktrees and their status",
)
parser.add_argument(
"--cleanup-worktrees",
action="store_true",
help="Remove all spec worktrees and their branches (with confirmation)",
)
# Force bypass
parser.add_argument(
"--force",
action="store_true",
help="Skip approval check and start build anyway (for debugging)",
)
# Base branch for worktree creation
parser.add_argument(
"--base-branch",
type=str,
default=None,
help="Base branch for creating worktrees (default: auto-detect or current branch)",
)
# Batch task management
parser.add_argument(
"--batch-create",
type=str,
default=None,
metavar="FILE",
help="Create multiple tasks from a batch JSON file",
)
parser.add_argument(
"--batch-status",
action="store_true",
help="Show status of all specs in the project",
)
parser.add_argument(
"--batch-cleanup",
action="store_true",
help="Clean up completed specs (dry-run by default)",
)
parser.add_argument(
"--no-dry-run",
action="store_true",
help="Actually delete files in cleanup (not just preview)",
)
return parser.parse_args()
def main() -> None:
"""Main CLI entry point."""
# Set up environment first
setup_environment()
# Parse arguments
args = parse_args()
# Import debug functions after environment setup
from debug import debug, debug_error, debug_section, debug_success
debug_section("run.py", "Starting Auto-Build Framework")
debug("run.py", "Arguments parsed", args=vars(args))
# Determine project directory
project_dir = get_project_dir(args.project_dir)
debug("run.py", f"Using project directory: {project_dir}")
# Get model (with env var fallback)
model = args.model or os.environ.get("AUTO_BUILD_MODEL", DEFAULT_MODEL)
# Handle --list command
if args.list:
print_banner()
print_specs_list(project_dir)
return
# Handle --list-worktrees command
if args.list_worktrees:
handle_list_worktrees_command(project_dir)
return
# Handle --cleanup-worktrees command
if args.cleanup_worktrees:
handle_cleanup_worktrees_command(project_dir)
return
# Handle batch commands
if args.batch_create:
handle_batch_create_command(args.batch_create, str(project_dir))
return
if args.batch_status:
handle_batch_status_command(str(project_dir))
return
if args.batch_cleanup:
handle_batch_cleanup_command(str(project_dir), dry_run=not args.no_dry_run)
return
# Require --spec if not listing
if not args.spec:
print_banner()
print("\nError: --spec is required")
print("\nUsage:")
print(" python auto-claude/run.py --list # See all specs")
print(" python auto-claude/run.py --spec 001 # Run a spec")
print("\nCreate a new spec with:")
print(" claude /spec")
sys.exit(1)
# Find the spec
debug("run.py", "Finding spec", spec_identifier=args.spec)
spec_dir = find_spec(project_dir, args.spec)
if not spec_dir:
debug_error("run.py", "Spec not found", spec=args.spec)
print_banner()
print(f"\nError: Spec '{args.spec}' not found")
print("\nAvailable specs:")
print_specs_list(project_dir)
sys.exit(1)
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
# Handle build management commands
if args.merge_preview:
from cli.workspace_commands import handle_merge_preview_command
result = handle_merge_preview_command(
project_dir, spec_dir.name, base_branch=args.base_branch
)
# Output as JSON for the UI to parse
import json
print(json.dumps(result))
return
if args.merge:
success = handle_merge_command(
project_dir,
spec_dir.name,
no_commit=args.no_commit,
base_branch=args.base_branch,
)
if not success:
sys.exit(1)
return
if args.review:
handle_review_command(project_dir, spec_dir.name)
return
if args.discard:
handle_discard_command(project_dir, spec_dir.name)
return
# Handle QA commands
if args.qa_status:
handle_qa_status_command(spec_dir)
return
if args.review_status:
handle_review_status_command(spec_dir)
return
if args.qa:
handle_qa_command(
project_dir=project_dir,
spec_dir=spec_dir,
model=model,
verbose=args.verbose,
)
return
# Handle --followup command
if args.followup:
handle_followup_command(
project_dir=project_dir,
spec_dir=spec_dir,
model=model,
verbose=args.verbose,
)
return
# Normal build flow
handle_build_command(
project_dir=project_dir,
spec_dir=spec_dir,
model=model,
max_iterations=args.max_iterations,
verbose=args.verbose,
force_isolated=args.isolated,
force_direct=args.direct,
auto_continue=args.auto_continue,
skip_qa=args.skip_qa,
force_bypass_approval=args.force,
base_branch=args.base_branch,
)
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
"""
QA Commands
===========
CLI commands for QA validation (run QA, check status)
"""
import asyncio
import sys
from pathlib import Path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from progress import count_subtasks
from qa_loop import (
is_qa_approved,
print_qa_status,
run_qa_validation_loop,
should_run_qa,
)
from review import ReviewState, display_review_status
from ui import (
Icons,
icon,
info,
success,
warning,
)
from .utils import print_banner, validate_environment
def handle_qa_status_command(spec_dir: Path) -> None:
"""
Handle the --qa-status command.
Args:
spec_dir: Spec directory path
"""
print_banner()
print(f"\nSpec: {spec_dir.name}\n")
print_qa_status(spec_dir)
def handle_review_status_command(spec_dir: Path) -> None:
"""
Handle the --review-status command.
Args:
spec_dir: Spec directory path
"""
print_banner()
print(f"\nSpec: {spec_dir.name}\n")
display_review_status(spec_dir)
# Also show if approval is valid for build
review_state = ReviewState.load(spec_dir)
print()
if review_state.is_approval_valid(spec_dir):
print(success(f"{icon(Icons.SUCCESS)} Ready to build - approval is valid."))
elif review_state.approved:
print(
warning(
f"{icon(Icons.WARNING)} Spec changed since approval - re-review required."
)
)
else:
print(info(f"{icon(Icons.INFO)} Review required before building."))
print()
def handle_qa_command(
project_dir: Path,
spec_dir: Path,
model: str,
verbose: bool = False,
) -> None:
"""
Handle the --qa command (run QA validation loop).
Args:
project_dir: Project root directory
spec_dir: Spec directory path
model: Model to use for QA
verbose: Enable verbose output
"""
print_banner()
print(f"\nRunning QA validation for: {spec_dir.name}")
if not validate_environment(spec_dir):
sys.exit(1)
# Check if there's pending human feedback that needs to be processed
# Human feedback takes priority over "already approved" status
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
has_human_feedback = fix_request_file.exists()
if not should_run_qa(spec_dir) and not has_human_feedback:
if is_qa_approved(spec_dir):
print("\n✅ Build already approved by QA.")
else:
completed, total = count_subtasks(spec_dir)
print(f"\n❌ Build not complete ({completed}/{total} subtasks).")
print("Complete all subtasks before running QA validation.")
return
if has_human_feedback:
print("\n📝 Human feedback detected - processing fix request...")
try:
approved = asyncio.run(
run_qa_validation_loop(
project_dir=project_dir,
spec_dir=spec_dir,
model=model,
verbose=verbose,
)
)
if approved:
print("\n✅ QA validation passed. Ready for merge.")
else:
print("\n❌ QA validation incomplete. See reports for details.")
sys.exit(1)
except KeyboardInterrupt:
print("\n\nQA validation paused.")
print(f"Resume with: python auto-claude/run.py --spec {spec_dir.name} --qa")
+191
View File
@@ -0,0 +1,191 @@
"""
Spec Commands
=============
CLI commands for managing specs (listing, finding, etc.)
"""
import sys
from pathlib import Path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from progress import count_subtasks
from workspace import get_existing_build_worktree
from .utils import get_specs_dir
def list_specs(project_dir: Path) -> list[dict]:
"""
List all specs in the project.
Args:
project_dir: Project root directory
Returns:
List of spec info dicts with keys: number, name, path, status, progress
"""
specs_dir = get_specs_dir(project_dir)
specs = []
if not specs_dir.exists():
return specs
for spec_folder in sorted(specs_dir.iterdir()):
if not spec_folder.is_dir():
continue
# Parse folder name (e.g., "001-initial-app")
folder_name = spec_folder.name
parts = folder_name.split("-", 1)
if len(parts) != 2 or not parts[0].isdigit():
continue
number = parts[0]
name = parts[1]
# Check for spec.md
spec_file = spec_folder / "spec.md"
if not spec_file.exists():
continue
# Check for existing build in worktree
has_build = get_existing_build_worktree(project_dir, folder_name) is not None
# Check progress via implementation_plan.json
plan_file = spec_folder / "implementation_plan.json"
if plan_file.exists():
completed, total = count_subtasks(spec_folder)
if total > 0:
if completed == total:
status = "complete"
else:
status = "in_progress"
progress = f"{completed}/{total}"
else:
status = "initialized"
progress = "0/0"
else:
status = "pending"
progress = "-"
# Add build indicator
if has_build:
status = f"{status} (has build)"
specs.append(
{
"number": number,
"name": name,
"folder": folder_name,
"path": spec_folder,
"status": status,
"progress": progress,
"has_build": has_build,
}
)
return specs
def print_specs_list(project_dir: Path, auto_create: bool = True) -> None:
"""Print a formatted list of all specs.
Args:
project_dir: Project root directory
auto_create: If True and no specs exist, automatically launch spec creation
"""
import subprocess
specs = list_specs(project_dir)
if not specs:
print("\nNo specs found.")
if auto_create:
# Get the backend directory and find spec_runner.py
backend_dir = Path(__file__).parent.parent
spec_runner = backend_dir / "runners" / "spec_runner.py"
# Find Python executable - use current interpreter
python_path = sys.executable
if spec_runner.exists() and python_path:
# Quick prompt for task description
print("\n" + "=" * 60)
print(" QUICK START")
print("=" * 60)
print("\nWhat do you want to build?")
print(
"(Enter a brief description, or press Enter for interactive mode)\n"
)
try:
task = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
return
if task:
# Direct mode: create spec and start building
print(f"\nStarting build for: {task}\n")
subprocess.run(
[
python_path,
str(spec_runner),
"--task",
task,
"--complexity",
"simple",
"--auto-approve",
],
cwd=project_dir,
)
else:
# Interactive mode
print("\nLaunching interactive mode...\n")
subprocess.run(
[python_path, str(spec_runner), "--interactive"],
cwd=project_dir,
)
return
else:
print("\nCreate your first spec:")
print(" python runners/spec_runner.py --interactive")
else:
print("\nCreate your first spec:")
print(" python runners/spec_runner.py --interactive")
return
print("\n" + "=" * 70)
print(" AVAILABLE SPECS")
print("=" * 70)
print()
# Status symbols
status_symbols = {
"complete": "[OK]",
"in_progress": "[..]",
"initialized": "[--]",
"pending": "[ ]",
}
for spec in specs:
# Get base status for symbol
base_status = spec["status"].split(" ")[0]
symbol = status_symbols.get(base_status, "[??]")
print(f" {symbol} {spec['folder']}")
status_line = f" Status: {spec['status']} | Subtasks: {spec['progress']}"
print(status_line)
print()
print("-" * 70)
print("\nTo run a spec:")
print(" python auto-claude/run.py --spec 001")
print(" python auto-claude/run.py --spec 001-feature-name")
print()

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