Merge PR #52: fix: save Claude OAuth token to active profile during GitHub setup flow

This commit is contained in:
AndyMik90
2025-12-20 01:12:01 +01:00
40 changed files with 2231 additions and 517 deletions
+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('./auto-claude-ui/package.json').version")
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "Package.json version: $PACKAGE_VERSION"
- name: Compare versions
run: |
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
echo "=========================================="
echo "Version Validation"
echo "=========================================="
echo "Git tag version: v$TAG_VERSION"
echo "package.json version: $PACKAGE_VERSION"
echo "=========================================="
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo ""
echo "❌ ERROR: Version mismatch detected!"
echo ""
echo "The version in package.json ($PACKAGE_VERSION) does not match"
echo "the git tag version ($TAG_VERSION)."
echo ""
echo "To fix this:"
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
echo " 2. Update package.json version to $TAG_VERSION"
echo " 3. Commit the change"
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
echo ""
echo "Or use the automated script:"
echo " node scripts/bump-version.js $TAG_VERSION"
echo ""
exit 1
fi
echo ""
echo "✅ SUCCESS: Versions match!"
echo ""
- name: Version validation result
if: success()
run: |
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
+15
View File
@@ -81,6 +81,21 @@ auto-claude/.venv/bin/pytest tests/ -m "not slow"
python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature --checkpoint all
```
### Releases
```bash
# Automated version bump and release (recommended)
node scripts/bump-version.js patch # 2.5.5 -> 2.5.6
node scripts/bump-version.js minor # 2.5.5 -> 2.6.0
node scripts/bump-version.js major # 2.5.5 -> 3.0.0
node scripts/bump-version.js 2.6.0 # Set specific version
# Then push to trigger GitHub release workflows
git push origin main
git push origin v2.6.0
```
See [RELEASE.md](RELEASE.md) for detailed release process documentation.
## Architecture
### Core Pipeline
+186
View File
@@ -0,0 +1,186 @@
# Release Process
This document describes how to create a new release of Auto Claude.
## Automated Release Process (Recommended)
We provide an automated script that handles version bumping, git commits, and tagging to ensure version consistency.
### Prerequisites
- Clean git working directory (no uncommitted changes)
- You're on the branch you want to release from (usually `main`)
### Steps
1. **Run the version bump script:**
```bash
# Bump patch version (2.5.5 -> 2.5.6)
node scripts/bump-version.js patch
# Bump minor version (2.5.5 -> 2.6.0)
node scripts/bump-version.js minor
# Bump major version (2.5.5 -> 3.0.0)
node scripts/bump-version.js major
# Set specific version
node scripts/bump-version.js 2.6.0
```
This script will:
- ✅ Update `auto-claude-ui/package.json` with the new version
- ✅ Create a git commit with the version change
- ✅ Create a git tag (e.g., `v2.5.6`)
- ⚠️ **NOT** push to remote (you control when to push)
2. **Review the changes:**
```bash
git log -1 # View the commit
git show v2.5.6 # View the tag
```
3. **Push to GitHub:**
```bash
# Push the commit
git push origin main
# Push the tag
git push origin v2.5.6
```
4. **Create GitHub Release:**
- Go to [GitHub Releases](https://github.com/AndyMik90/Auto-Claude/releases)
- Click "Draft a new release"
- Select the tag you just pushed (e.g., `v2.5.6`)
- Add release notes (describe what changed)
- Click "Publish release"
5. **Automated builds will trigger:**
- ✅ Version validation workflow will verify version consistency
- ✅ Tests will run (`test-on-tag.yml`)
- ✅ Native module prebuilds will be created (`build-prebuilds.yml`)
- ✅ Discord notification will be sent (`discord-release.yml`)
## Manual Release Process (Not Recommended)
If you need to create a release manually, follow these steps **carefully** to avoid version mismatches:
1. **Update `auto-claude-ui/package.json`:**
```json
{
"version": "2.5.6"
}
```
2. **Commit the change:**
```bash
git add auto-claude-ui/package.json
git commit -m "chore: bump version to 2.5.6"
```
3. **Create and push tag:**
```bash
git tag -a v2.5.6 -m "Release v2.5.6"
git push origin main
git push origin v2.5.6
```
4. **Create GitHub Release** (same as step 4 above)
## Version Validation
A GitHub Action automatically validates that the version in `package.json` matches the git tag.
If there's a mismatch, the workflow will **fail** with a clear error message:
```
❌ ERROR: Version mismatch detected!
The version in package.json (2.5.0) does not match
the git tag version (2.5.5).
To fix this:
1. Delete this tag: git tag -d v2.5.5
2. Update package.json version to 2.5.5
3. Commit the change
4. Recreate the tag: git tag -a v2.5.5 -m 'Release v2.5.5'
```
This validation ensures we never ship a release where the updater shows the wrong version.
## Troubleshooting
### Version Mismatch Error
If you see a version mismatch error in GitHub Actions:
1. **Delete the incorrect tag:**
```bash
git tag -d v2.5.6 # Delete locally
git push origin :refs/tags/v2.5.6 # Delete remotely
```
2. **Use the automated script:**
```bash
node scripts/bump-version.js 2.5.6
git push origin main
git push origin v2.5.6
```
### Git Working Directory Not Clean
If the version bump script fails with "Git working directory is not clean":
```bash
# Commit or stash your changes first
git status
git add .
git commit -m "your changes"
# Then run the version bump script
node scripts/bump-version.js patch
```
## Release Checklist
Use this checklist when creating a new release:
- [ ] All tests passing on main branch
- [ ] CHANGELOG updated (if applicable)
- [ ] Run `node scripts/bump-version.js <type>`
- [ ] Review commit and tag
- [ ] Push commit and tag to GitHub
- [ ] Create GitHub Release with release notes
- [ ] Verify version validation passed
- [ ] Verify builds completed successfully
- [ ] Test the updater shows correct version
## What Gets Released
When you create a release, the following are built and published:
1. **Native module prebuilds** - Windows node-pty binaries
2. **Electron app packages** - Desktop installers (triggered manually or via electron-builder)
3. **Discord notification** - Sent to the Auto Claude community
## Version Numbering
We follow [Semantic Versioning (SemVer)](https://semver.org/):
- **MAJOR** version (X.0.0) - Breaking changes
- **MINOR** version (0.X.0) - New features (backward compatible)
- **PATCH** version (0.0.X) - Bug fixes (backward compatible)
Examples:
- `2.5.5 -> 2.5.6` - Bug fix
- `2.5.6 -> 2.6.0` - New feature
- `2.6.0 -> 3.0.0` - Breaking change
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineConfig({
index: resolve(__dirname, 'src/main/index.ts')
},
// Only node-pty needs to be external (native module rebuilt by electron-builder)
external: ['node-pty']
external: ['@lydell/node-pty']
}
}
},
+10 -8
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.5.0",
"version": "2.5.5",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"main": "./out/main/index.js",
"author": "Auto Claude Team",
@@ -30,6 +30,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lydell/node-pty": "^1.1.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
@@ -59,7 +60,6 @@
"ioredis": "^5.8.2",
"lucide-react": "^0.560.0",
"motion": "^12.23.26",
"node-pty": "^1.1.0-beta42",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
@@ -104,12 +104,13 @@
"pnpm": {
"overrides": {
"electron-builder-squirrel-windows": "^26.0.12",
"dmg-builder": "^26.0.12"
"dmg-builder": "^26.0.12",
"node-pty": "npm:@lydell/node-pty@^1.1.0"
},
"onlyBuiltDependencies": [
"electron",
"esbuild",
"node-pty"
"electron-winstaller",
"esbuild"
]
},
"build": {
@@ -132,8 +133,8 @@
],
"extraResources": [
{
"from": "node_modules/node-pty",
"to": "node_modules/node-pty"
"from": "node_modules/@lydell/node-pty",
"to": "node_modules/@lydell/node-pty"
},
{
"from": "resources/icon.ico",
@@ -180,5 +181,6 @@
"*.{ts,tsx}": [
"eslint --fix"
]
}
},
"packageManager": "[email protected]+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa"
}
+645 -335
View File
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ import { ProcessType, ExecutionProgressData } from './types';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
import { projectStore } from '../project-store';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { findPythonCommand, parsePythonCommand } from '../python-detector';
/**
* Process spawning and lifecycle management
@@ -17,7 +18,8 @@ export class AgentProcessManager {
private state: AgentState;
private events: AgentEvents;
private emitter: EventEmitter;
private pythonPath: string = 'python3';
// Auto-detect Python command on initialization
private pythonPath: string = findPythonCommand() || 'python';
private autoBuildSourcePath: string = '';
constructor(state: AgentState, events: AgentEvents, emitter: EventEmitter) {
@@ -161,7 +163,9 @@ export class AgentProcessManager {
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
const profileEnv = getProfileEnv();
const childProcess = spawn(this.pythonPath, args, {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
cwd,
env: {
...process.env,
+7 -2
View File
@@ -8,6 +8,7 @@ import { AgentProcessManager } from './agent-process';
import { IdeationConfig } from './types';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { parsePythonCommand } from '../python-detector';
/**
* Queue management for ideation and roadmap generation
@@ -206,7 +207,9 @@ export class AgentQueueManager {
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
});
const childProcess = spawn(pythonPath, args, {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
cwd,
env: finalEnv
});
@@ -441,7 +444,9 @@ export class AgentQueueManager {
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
});
const childProcess = spawn(pythonPath, args, {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
cwd,
env: finalEnv
});
@@ -27,13 +27,15 @@ import {
getCommits,
getBranchDiffCommits
} from './git-integration';
import { findPythonCommand } from '../python-detector';
/**
* Main changelog service - orchestrates all changelog operations
* Delegates to specialized modules for specific concerns
*/
export class ChangelogService extends EventEmitter {
private pythonPath: string = 'python3';
// Auto-detect Python command on initialization
private pythonPath: string = findPythonCommand() || 'python';
private claudePath: string = 'claude';
private autoBuildSourcePath: string = '';
private cachedEnv: Record<string, string> | null = null;
@@ -12,6 +12,7 @@ import { buildChangelogPrompt, buildGitPrompt, createGenerationScript } from './
import { extractChangelog } from './parser';
import { getCommits, getBranchDiffCommits } from './git-integration';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
import { parsePythonCommand } from '../python-detector';
/**
* Core changelog generation logic
@@ -139,7 +140,9 @@ export class ChangelogGenerator extends EventEmitter {
// Build environment with explicit critical variables
const spawnEnv = this.buildSpawnEnvironment();
const childProcess = spawn(this.pythonPath, ['-c', script], {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
cwd: this.autoBuildSourcePath,
env: spawnEnv
});
@@ -3,6 +3,7 @@ import * as path from 'path';
import * as os from 'os';
import type { GitCommit } from '../../shared/types';
import { getProfileEnv } from '../rate-limit-detector';
import { parsePythonCommand } from '../python-detector';
interface VersionSuggestion {
version: string;
@@ -52,7 +53,9 @@ export class VersionSuggester {
const spawnEnv = this.buildSpawnEnvironment();
return new Promise((resolve, _reject) => {
const childProcess = spawn(this.pythonPath, ['-c', script], {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
cwd: this.autoBuildSourcePath,
env: spawnEnv
});
+3 -1
View File
@@ -2,13 +2,15 @@ import path from 'path';
import { existsSync, readFileSync } from 'fs';
import { app } from 'electron';
import { getProfileEnv } from '../rate-limit-detector';
import { findPythonCommand } from '../python-detector';
/**
* Configuration manager for insights service
* Handles path detection and environment variable loading
*/
export class InsightsConfig {
private pythonPath: string = 'python3';
// Auto-detect Python command on initialization
private pythonPath: string = findPythonCommand() || 'python';
private autoBuildSourcePath: string = '';
/**
@@ -8,8 +8,8 @@ import type { IPCResult, FileNode } from '../../shared/types';
const IGNORED_DIRS = new Set([
'node_modules', '.git', '__pycache__', 'dist', 'build',
'.next', '.nuxt', 'coverage', '.cache', '.venv', 'venv',
'.idea', '.vscode', 'out', '.turbo', '.auto-claude',
'.worktrees', 'vendor', 'target', '.gradle', '.maven'
'out', '.turbo', '.worktrees',
'vendor', 'target', '.gradle', '.maven'
]);
/**
@@ -29,8 +29,11 @@ export function registerFileHandlers(): void {
// Filter and map entries
const nodes: FileNode[] = [];
for (const entry of entries) {
// Skip hidden files (except .env which is often useful)
if (entry.name.startsWith('.') && entry.name !== '.env') continue;
// Skip hidden files (not directories) except useful ones like .env, .gitignore
if (!entry.isDirectory() && entry.name.startsWith('.') &&
!['.env', '.gitignore', '.env.example', '.env.local'].includes(entry.name)) {
continue;
}
// Skip ignored directories
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
@@ -6,7 +6,7 @@ import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult, GitHubIssue } from '../../../shared/types';
import { projectStore } from '../../project-store';
import { getGitHubConfig, githubFetch } from './utils';
import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils';
import type { GitHubAPIIssue, GitHubAPIComment } from './types';
/**
@@ -57,16 +57,32 @@ export function registerGetIssues(): void {
}
try {
const normalizedRepo = normalizeRepoReference(config.repo);
if (!normalizedRepo) {
return {
success: false,
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
};
}
const issues = await githubFetch(
config.token,
`/repos/${config.repo}/issues?state=${state}&per_page=100&sort=updated`
) as GitHubAPIIssue[];
`/repos/${normalizedRepo}/issues?state=${state}&per_page=100&sort=updated`
);
// Ensure issues is an array
if (!Array.isArray(issues)) {
return {
success: false,
error: 'Unexpected response format from GitHub API'
};
}
// Filter out pull requests
const issuesOnly = issues.filter(issue => !issue.pull_request);
const issuesOnly = issues.filter((issue: GitHubAPIIssue) => !issue.pull_request);
const result: GitHubIssue[] = issuesOnly.map(issue =>
transformIssue(issue, config.repo)
const result: GitHubIssue[] = issuesOnly.map((issue: GitHubAPIIssue) =>
transformIssue(issue, normalizedRepo)
);
return { success: true, data: result };
@@ -98,12 +114,20 @@ export function registerGetIssue(): void {
}
try {
const normalizedRepo = normalizeRepoReference(config.repo);
if (!normalizedRepo) {
return {
success: false,
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
};
}
const issue = await githubFetch(
config.token,
`/repos/${config.repo}/issues/${issueNumber}`
`/repos/${normalizedRepo}/issues/${issueNumber}`
) as GitHubAPIIssue;
const result = transformIssue(issue, config.repo);
const result = transformIssue(issue, normalizedRepo);
return { success: true, data: result };
} catch (error) {
@@ -134,9 +158,17 @@ export function registerGetIssueComments(): void {
}
try {
const normalizedRepo = normalizeRepoReference(config.repo);
if (!normalizedRepo) {
return {
success: false,
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
};
}
const comments = await githubFetch(
config.token,
`/repos/${config.repo}/issues/${issueNumber}/comments`
`/repos/${normalizedRepo}/issues/${issueNumber}/comments`
) as GitHubAPIComment[];
return { success: true, data: comments };
@@ -6,7 +6,7 @@ import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult, GitHubRepository, GitHubSyncStatus } from '../../../shared/types';
import { projectStore } from '../../project-store';
import { getGitHubConfig, githubFetch } from './utils';
import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils';
import type { GitHubAPIRepository } from './types';
/**
@@ -33,16 +33,28 @@ export function registerCheckConnection(): void {
}
try {
// Normalize repo reference (handles full URLs, git URLs, etc.)
const normalizedRepo = normalizeRepoReference(config.repo);
if (!normalizedRepo) {
return {
success: true,
data: {
connected: false,
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
}
};
}
// Fetch repo info
const repoData = await githubFetch(
config.token,
`/repos/${config.repo}`
`/repos/${normalizedRepo}`
) as { full_name: string; description?: string };
// Count open issues
const issuesData = await githubFetch(
config.token,
`/repos/${config.repo}/issues?state=open&per_page=1`
`/repos/${normalizedRepo}/issues?state=open&per_page=1`
) as unknown[];
const openCount = Array.isArray(issuesData) ? issuesData.length : 0;
@@ -71,7 +83,7 @@ export function registerCheckConnection(): void {
}
/**
* Get list of GitHub repositories
* Get list of GitHub repositories (personal + organization)
*/
export function registerGetRepositories(): void {
ipcMain.handle(
@@ -88,9 +100,11 @@ export function registerGetRepositories(): void {
}
try {
// Fetch user's personal + organization repos
// affiliation parameter includes: owner, collaborator, organization_member
const repos = await githubFetch(
config.token,
'/user/repos?per_page=100&sort=updated'
'/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member'
) as GitHubAPIRepository[];
const result: GitHubRepository[] = repos.map(repo => ({
@@ -54,6 +54,32 @@ export function getGitHubConfig(project: Project): GitHubConfig | null {
}
}
/**
* Normalize a GitHub repository reference to owner/repo format
* Handles:
* - owner/repo (already normalized)
* - https://github.com/owner/repo
* - https://github.com/owner/repo.git
* - [email protected]:owner/repo.git
*/
export function normalizeRepoReference(repo: string): string {
if (!repo) return '';
// Remove trailing .git if present
let normalized = repo.replace(/\.git$/, '');
// Handle full GitHub URLs
if (normalized.startsWith('https://github.com/')) {
normalized = normalized.replace('https://github.com/', '');
} else if (normalized.startsWith('http://github.com/')) {
normalized = normalized.replace('http://github.com/', '');
} else if (normalized.startsWith('[email protected]:')) {
normalized = normalized.replace('[email protected]:', '');
}
return normalized.trim();
}
/**
* Make a request to the GitHub API
*/
@@ -3,6 +3,7 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/con
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { spawnSync } from 'child_process';
import { AgentManager } from '../../agent';
import { fileWatcher } from '../../file-watcher';
import { findTaskAndProject } from './shared';
@@ -200,6 +201,11 @@ export function registerTaskExecutionHandlers(
task.specId
);
// Check if worktree exists - QA needs to run in the worktree where the build happened
const worktreePath = path.join(project.path, '.worktrees', task.specId);
const worktreeSpecDir = path.join(worktreePath, specsBaseDir, task.specId);
const hasWorktree = existsSync(worktreePath);
if (approved) {
// Write approval to QA report
const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT);
@@ -217,15 +223,60 @@ export function registerTaskExecutionHandlers(
);
}
} else {
// Write feedback for QA fixer
const fixRequestPath = path.join(specDir, 'QA_FIX_REQUEST.md');
// Reset and discard all changes from worktree merge in main
// The worktree still has all changes, so nothing is lost
if (hasWorktree) {
// Step 1: Unstage all changes
const resetResult = spawnSync('git', ['reset', 'HEAD'], {
cwd: project.path,
encoding: 'utf-8',
stdio: 'pipe'
});
if (resetResult.status === 0) {
console.log('[TASK_REVIEW] Unstaged changes in main');
}
// Step 2: Discard all working tree changes (restore to pre-merge state)
const checkoutResult = spawnSync('git', ['checkout', '--', '.'], {
cwd: project.path,
encoding: 'utf-8',
stdio: 'pipe'
});
if (checkoutResult.status === 0) {
console.log('[TASK_REVIEW] Discarded working tree changes in main');
}
// Step 3: Clean untracked files that came from the merge
const cleanResult = spawnSync('git', ['clean', '-fd'], {
cwd: project.path,
encoding: 'utf-8',
stdio: 'pipe'
});
if (cleanResult.status === 0) {
console.log('[TASK_REVIEW] Cleaned untracked files in main');
}
console.log('[TASK_REVIEW] Main branch restored to pre-merge state');
}
// Write feedback for QA fixer - write to WORKTREE spec dir if it exists
// The QA process runs in the worktree where the build and implementation_plan.json are
const targetSpecDir = hasWorktree ? worktreeSpecDir : specDir;
const fixRequestPath = path.join(targetSpecDir, 'QA_FIX_REQUEST.md');
console.warn('[TASK_REVIEW] Writing QA fix request to:', fixRequestPath);
console.warn('[TASK_REVIEW] hasWorktree:', hasWorktree, 'worktreePath:', worktreePath);
writeFileSync(
fixRequestPath,
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n`
);
// Restart QA process with dev mode
agentManager.startQAProcess(taskId, project.path, task.specId);
// Restart QA process - use worktree path if it exists, otherwise main project
// The QA process needs to run where the implementation_plan.json with completed subtasks is
const qaProjectPath = hasWorktree ? worktreePath : project.path;
console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath);
agentManager.startQAProcess(taskId, qaProjectPath, task.specId);
const mainWindow = getMainWindow();
if (mainWindow) {
@@ -3,12 +3,13 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS } from '../../../shared/constants';
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem } from '../../../shared/types';
import path from 'path';
import { existsSync, readdirSync, statSync } from 'fs';
import { execSync, spawn } from 'child_process';
import { execSync, spawn, spawnSync } from 'child_process';
import { projectStore } from '../../project-store';
import { PythonEnvManager } from '../../python-env-manager';
import { getEffectiveSourcePath } from '../../auto-claude-updater';
import { getProfileEnv } from '../../rate-limit-detector';
import { findTaskAndProject } from './shared';
import { findPythonCommand, parsePythonCommand } from '../../python-detector';
/**
* Register worktree management handlers
@@ -272,6 +273,31 @@ export function registerWorktreeHandlers(
const worktreePath = path.join(project.path, '.worktrees', task.specId);
debug('Worktree path:', worktreePath, 'exists:', existsSync(worktreePath));
// Check if changes are already staged (for stage-only mode)
if (options?.noCommit) {
const stagedResult = spawnSync('git', ['diff', '--staged', '--name-only'], {
cwd: project.path,
encoding: 'utf-8'
});
if (stagedResult.status === 0 && stagedResult.stdout?.trim()) {
const stagedFiles = stagedResult.stdout.trim().split('\n');
debug('Changes already staged:', stagedFiles.length, 'files');
// Return success - changes are already staged
return {
success: true,
data: {
success: true,
merged: false,
message: `Changes already staged (${stagedFiles.length} files). Review with git diff --staged.`,
staged: true,
alreadyStaged: true,
projectPath: project.path
}
};
}
}
// Get git status before merge
try {
const gitStatusBefore = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' });
@@ -294,7 +320,7 @@ export function registerWorktreeHandlers(
args.push('--no-commit');
}
const pythonPath = pythonEnvManager.getPythonPath() || 'python3';
const pythonPath = pythonEnvManager.getPythonPath() || findPythonCommand() || 'python';
debug('Running command:', pythonPath, args.join(' '));
debug('Working directory:', sourcePath);
@@ -310,7 +336,9 @@ export function registerWorktreeHandlers(
let timeoutId: NodeJS.Timeout | null = null;
let resolved = false;
const mergeProcess = spawn(pythonPath, args, {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
const mergeProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
cwd: sourcePath,
env: {
...process.env,
@@ -622,14 +650,16 @@ export function registerWorktreeHandlers(
'--merge-preview'
];
const pythonPath = pythonEnvManager.getPythonPath() || 'python3';
const pythonPath = pythonEnvManager.getPythonPath() || findPythonCommand() || 'python';
console.warn('[IPC] Running merge preview:', pythonPath, args.join(' '));
// Get profile environment for consistency
const previewProfileEnv = getProfileEnv();
return new Promise((resolve) => {
const previewProcess = spawn(pythonPath, args, {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
const previewProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
cwd: sourcePath,
env: { ...process.env, ...previewProfileEnv, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1', DEBUG: 'true' }
});
@@ -0,0 +1,61 @@
import { execSync } from 'child_process';
/**
* Detect and return the best available Python command.
* Tries multiple candidates and returns the first one that works with Python 3.
*
* @returns The Python command to use, or null if none found
*/
export function findPythonCommand(): string | null {
const isWindows = process.platform === 'win32';
// On Windows, try py launcher first (most reliable), then python, then python3
// On Unix, try python3 first, then python
const candidates = isWindows
? ['py -3', 'python', 'python3', 'py']
: ['python3', 'python'];
for (const cmd of candidates) {
try {
const version = execSync(`${cmd} --version`, {
stdio: 'pipe',
timeout: 5000,
windowsHide: true
}).toString();
if (version.includes('Python 3')) {
return cmd;
}
} catch {
// Command not found or errored, try next
continue;
}
}
// Fallback to platform-specific default
return isWindows ? 'python' : 'python3';
}
/**
* Get the default Python command for the current platform.
* This is a synchronous fallback that doesn't test if Python actually exists.
*
* @returns The default Python command for this platform
*/
export function getDefaultPythonCommand(): string {
return process.platform === 'win32' ? 'python' : 'python3';
}
/**
* Parse a Python command string into command and base arguments.
* Handles space-separated commands like "py -3".
*
* @param pythonPath - The Python command string (e.g., "python3", "py -3")
* @returns Tuple of [command, baseArgs] ready for use with spawn()
*/
export function parsePythonCommand(pythonPath: string): [string, string[]] {
const parts = pythonPath.split(' ');
const command = parts[0];
const baseArgs = parts.slice(1);
return [command, baseArgs];
}
+50 -13
View File
@@ -37,16 +37,11 @@ export class PythonEnvManager extends EventEmitter {
/**
* Get the path to pip in the venv
* Returns null - we use python -m pip instead for better compatibility
* @deprecated Use getVenvPythonPath() with -m pip instead
*/
private getVenvPipPath(): string | null {
if (!this.autoBuildSourcePath) return null;
const venvPip =
process.platform === 'win32'
? path.join(this.autoBuildSourcePath, '.venv', 'Scripts', 'pip.exe')
: path.join(this.autoBuildSourcePath, '.venv', 'bin', 'pip');
return venvPip;
return null; // Not used - we use python -m pip
}
/**
@@ -181,16 +176,54 @@ export class PythonEnvManager extends EventEmitter {
}
/**
* Install dependencies from requirements.txt
* Bootstrap pip in the venv using ensurepip
*/
private async bootstrapPip(): Promise<boolean> {
const venvPython = this.getVenvPythonPath();
if (!venvPython || !existsSync(venvPython)) {
return false;
}
console.warn('[PythonEnvManager] Bootstrapping pip...');
return new Promise((resolve) => {
const proc = spawn(venvPython, ['-m', 'ensurepip'], {
cwd: this.autoBuildSourcePath!,
stdio: 'pipe'
});
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
if (code === 0) {
console.warn('[PythonEnvManager] Pip bootstrapped successfully');
resolve(true);
} else {
console.error('[PythonEnvManager] Failed to bootstrap pip:', stderr);
resolve(false);
}
});
proc.on('error', (err) => {
console.error('[PythonEnvManager] Error bootstrapping pip:', err);
resolve(false);
});
});
}
/**
* Install dependencies from requirements.txt using python -m pip
*/
private async installDeps(): Promise<boolean> {
if (!this.autoBuildSourcePath) return false;
const venvPip = this.getVenvPipPath();
const venvPython = this.getVenvPythonPath();
const requirementsPath = path.join(this.autoBuildSourcePath, 'requirements.txt');
if (!venvPip || !existsSync(venvPip)) {
this.emit('error', 'Pip not found in virtual environment');
if (!venvPython || !existsSync(venvPython)) {
this.emit('error', 'Python not found in virtual environment');
return false;
}
@@ -199,11 +232,15 @@ export class PythonEnvManager extends EventEmitter {
return false;
}
// Bootstrap pip first if needed
await this.bootstrapPip();
this.emit('status', 'Installing Python dependencies (this may take a minute)...');
console.warn('[PythonEnvManager] Installing dependencies from:', requirementsPath);
return new Promise((resolve) => {
const proc = spawn(venvPip, ['install', '-r', requirementsPath], {
// Use python -m pip for better compatibility across Python versions
const proc = spawn(venvPython, ['-m', 'pip', 'install', '-r', requirementsPath], {
cwd: this.autoBuildSourcePath!,
stdio: 'pipe'
});
@@ -4,6 +4,7 @@ import { spawn } from 'child_process';
import { app } from 'electron';
import { EventEmitter } from 'events';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
import { findPythonCommand, parsePythonCommand } from './python-detector';
/**
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
@@ -20,7 +21,8 @@ function debug(...args: unknown[]): void {
* Service for generating terminal names from commands using Claude AI
*/
export class TerminalNameGenerator extends EventEmitter {
private pythonPath: string = 'python3';
// Auto-detect Python command on initialization
private pythonPath: string = findPythonCommand() || 'python';
private autoBuildSourcePath: string = '';
constructor() {
@@ -130,7 +132,9 @@ export class TerminalNameGenerator extends EventEmitter {
const profileEnv = getProfileEnv();
return new Promise((resolve) => {
const childProcess = spawn(this.pythonPath, ['-c', script], {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
cwd: autoBuildSource,
env: {
...process.env,
@@ -97,6 +97,7 @@ export function handleOAuthToken(
const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+|default)-/);
if (profileIdMatch) {
// Save to specific profile (profile login terminal)
const profileId = profileIdMatch[1];
const profileManager = getClaudeProfileManager();
const success = profileManager.setProfileToken(profileId, token, email || undefined);
@@ -118,16 +119,56 @@ export function handleOAuthToken(
console.error('[ClaudeIntegration] Failed to save OAuth token to profile:', profileId);
}
} else {
console.warn('[ClaudeIntegration] OAuth token detected but not in a profile login terminal');
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
email,
success: false,
message: 'Token detected but no profile associated with this terminal',
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
// No profile-specific terminal, save to active profile (GitHub OAuth flow, etc.)
console.warn('[ClaudeIntegration] OAuth token detected in non-profile terminal, saving to active profile');
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
// Defensive null check for active profile
if (!activeProfile) {
console.error('[ClaudeIntegration] Failed to save OAuth token: no active profile found');
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: undefined,
email,
success: false,
message: 'No active profile found',
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
return;
}
const success = profileManager.setProfileToken(activeProfile.id, token, email || undefined);
if (success) {
console.warn('[ClaudeIntegration] OAuth token auto-saved to active profile:', activeProfile.name);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: activeProfile.id,
email,
success: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
} else {
console.error('[ClaudeIntegration] Failed to save OAuth token to active profile:', activeProfile.name);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: activeProfile?.id,
email,
success: false,
message: 'Failed to save token to active profile',
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
}
}
}
@@ -10,7 +10,7 @@
import * as net from 'net';
import * as fs from 'fs';
import * as pty from 'node-pty';
import * as pty from '@lydell/node-pty';
const SOCKET_PATH =
process.platform === 'win32'
@@ -3,7 +3,7 @@
* Handles low-level PTY process creation and lifecycle
*/
import * as pty from 'node-pty';
import * as pty from '@lydell/node-pty';
import * as os from 'os';
import type { TerminalProcess, WindowGetter } from './types';
import { IPC_CHANNELS } from '../../shared/constants';
+1 -1
View File
@@ -1,4 +1,4 @@
import type * as pty from 'node-pty';
import type * as pty from '@lydell/node-pty';
import type { BrowserWindow } from 'electron';
/**
+6 -2
View File
@@ -4,6 +4,7 @@ import { spawn } from 'child_process';
import { app } from 'electron';
import { EventEmitter } from 'events';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
import { findPythonCommand, parsePythonCommand } from './python-detector';
/**
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
@@ -20,7 +21,8 @@ function debug(...args: unknown[]): void {
* Service for generating task titles from descriptions using Claude AI
*/
export class TitleGenerator extends EventEmitter {
private pythonPath: string = 'python3';
// Auto-detect Python command on initialization
private pythonPath: string = findPythonCommand() || 'python';
private autoBuildSourcePath: string = '';
constructor() {
@@ -129,7 +131,9 @@ export class TitleGenerator extends EventEmitter {
const profileEnv = getProfileEnv();
return new Promise((resolve) => {
const childProcess = spawn(this.pythonPath, ['-c', script], {
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
cwd: autoBuildSource,
env: {
...process.env,
+56 -6
View File
@@ -69,6 +69,8 @@ export function App() {
const [showInitDialog, setShowInitDialog] = useState(false);
const [pendingProject, setPendingProject] = useState<Project | null>(null);
const [isInitializing, setIsInitializing] = useState(false);
const [initSuccess, setInitSuccess] = useState(false);
const [initError, setInitError] = useState<string | null>(null);
const [skippedInitProjectId, setSkippedInitProjectId] = useState<string | null>(null);
// GitHub setup state (shown after Auto Claude init)
@@ -141,6 +143,8 @@ export function App() {
if (selectedProject && !selectedProject.autoBuildPath && skippedInitProjectId !== selectedProject.id) {
// Project exists but isn't initialized - show init dialog
setPendingProject(selectedProject);
setInitError(null); // Clear any previous errors
setInitSuccess(false); // Reset success flag
setShowInitDialog(true);
}
}, [selectedProject, skippedInitProjectId, isInitializing]);
@@ -232,6 +236,8 @@ export function App() {
if (project && !project.autoBuildPath) {
// Project doesn't have Auto Claude initialized, show init dialog
setPendingProject(project);
setInitError(null); // Clear any previous errors
setInitSuccess(false); // Reset success flag
setShowInitDialog(true);
}
}
@@ -244,24 +250,45 @@ export function App() {
if (!pendingProject) return;
const projectId = pendingProject.id;
console.log('[InitDialog] Starting initialization for project:', projectId);
setIsInitializing(true);
setInitSuccess(false);
setInitError(null); // Clear any previous errors
try {
const result = await initializeProject(projectId);
console.log('[InitDialog] Initialization result:', result);
if (result?.success) {
console.log('[InitDialog] Initialization successful, closing dialog');
// Get the updated project from store
const updatedProject = useProjectStore.getState().projects.find(p => p.id === projectId);
console.log('[InitDialog] Updated project:', updatedProject);
// Clear init dialog state
setPendingProject(null);
// Mark as successful to prevent onOpenChange from treating this as a skip
setInitSuccess(true);
setIsInitializing(false);
// Now close the dialog
setShowInitDialog(false);
setPendingProject(null);
// Show GitHub setup modal
if (updatedProject) {
setGitHubSetupProject(updatedProject);
setShowGitHubSetup(true);
}
} else {
// Initialization failed - show error but keep dialog open
console.log('[InitDialog] Initialization failed, showing error');
const errorMessage = result?.error || 'Failed to initialize Auto Claude. Please try again.';
setInitError(errorMessage);
setIsInitializing(false);
}
} finally {
} catch (error) {
// Unexpected error occurred
console.error('[InitDialog] Unexpected error during initialization:', error);
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
setInitError(errorMessage);
setIsInitializing(false);
}
};
@@ -274,10 +301,16 @@ export function App() {
if (!gitHubSetupProject) return;
try {
// NOTE: settings.githubToken is a GitHub access token (from gh CLI),
// NOT a Claude Code OAuth token. They are different things:
// - GitHub token: for GitHub API access (repo operations)
// - Claude token: for Claude AI access (run.py, roadmap, etc.)
// The user needs to separately authenticate with Claude using 'claude setup-token'
// Update project env config with GitHub settings
await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, {
githubEnabled: true,
githubToken: settings.githubToken,
githubToken: settings.githubToken, // GitHub token for repo access
githubRepo: settings.githubRepo
});
@@ -302,11 +335,14 @@ export function App() {
};
const handleSkipInit = () => {
console.log('[InitDialog] User skipped initialization');
if (pendingProject) {
setSkippedInitProjectId(pendingProject.id);
}
setShowInitDialog(false);
setPendingProject(null);
setInitError(null); // Clear any error when skipping
setInitSuccess(false); // Reset success flag
};
const handleGoToTask = (taskId: string) => {
@@ -471,9 +507,10 @@ export function App() {
{/* Initialize Auto Claude Dialog */}
<Dialog open={showInitDialog} onOpenChange={(open) => {
console.log('[InitDialog] onOpenChange called', { open, pendingProject: !!pendingProject, isInitializing, initSuccess });
// Only trigger skip if user manually closed the dialog
// Don't trigger if pendingProject is null (successful init) or if initializing
if (!open && pendingProject && !isInitializing) {
// Don't trigger if: successful init, no pending project, or currently initializing
if (!open && pendingProject && !isInitializing && !initSuccess) {
handleSkipInit();
}
}}>
@@ -509,6 +546,19 @@ export function App() {
</div>
</div>
)}
{initError && (
<div className="mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<div>
<p className="font-medium text-destructive">Initialization Failed</p>
<p className="text-muted-foreground mt-1">
{initError}
</p>
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleSkipInit} disabled={isInitializing}>
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import {
Github,
GitBranch,
Key,
Loader2,
CheckCircle2,
AlertCircle,
@@ -26,6 +27,7 @@ import {
SelectValue
} from './ui/select';
import { GitHubOAuthFlow } from './project-settings/GitHubOAuthFlow';
import { ClaudeOAuthFlow } from './project-settings/ClaudeOAuthFlow';
import type { Project, ProjectSettings } from '../../shared/types';
interface GitHubSetupModalProps {
@@ -36,15 +38,16 @@ interface GitHubSetupModalProps {
onSkip?: () => void;
}
type SetupStep = 'auth' | 'repo' | 'branch' | 'complete';
type SetupStep = 'github-auth' | 'claude-auth' | 'repo' | 'branch' | 'complete';
/**
* GitHub Setup Modal - Required setup flow after Auto Claude initialization
* Setup Modal - Required setup flow after Auto Claude initialization
*
* Flow:
* 1. Authenticate with GitHub (via gh CLI OAuth)
* 2. Detect/confirm repository
* 3. Select base branch for tasks (with recommended default)
* 1. Authenticate with GitHub (via gh CLI OAuth) - for repo operations
* 2. Authenticate with Claude (via claude CLI OAuth) - for AI features
* 3. Detect/confirm repository
* 4. Select base branch for tasks (with recommended default)
*/
export function GitHubSetupModal({
open,
@@ -53,7 +56,7 @@ export function GitHubSetupModal({
onComplete,
onSkip
}: GitHubSetupModalProps) {
const [step, setStep] = useState<SetupStep>('auth');
const [step, setStep] = useState<SetupStep>('github-auth');
const [githubToken, setGithubToken] = useState<string | null>(null);
const [githubRepo, setGithubRepo] = useState<string | null>(null);
const [detectedRepo, setDetectedRepo] = useState<string | null>(null);
@@ -67,7 +70,7 @@ export function GitHubSetupModal({
// Reset state when modal opens
useEffect(() => {
if (open) {
setStep('auth');
setStep('github-auth');
setGithubToken(null);
setGithubRepo(null);
setDetectedRepo(null);
@@ -140,9 +143,16 @@ export function GitHubSetupModal({
return branchList[0] || null;
};
// Handle OAuth success
const handleAuthSuccess = async (token: string) => {
// Handle GitHub OAuth success
const handleGitHubAuthSuccess = async (token: string) => {
setGithubToken(token);
// Move to Claude auth step
setStep('claude-auth');
};
// Handle Claude OAuth success
const handleClaudeAuthSuccess = async () => {
// Claude token is already saved to active profile by the OAuth flow
// Move to repo detection
await detectRepository();
};
@@ -161,7 +171,7 @@ export function GitHubSetupModal({
// Render step content
const renderStepContent = () => {
switch (step) {
case 'auth':
case 'github-auth':
return (
<>
<DialogHeader>
@@ -176,7 +186,29 @@ export function GitHubSetupModal({
<div className="py-4">
<GitHubOAuthFlow
onSuccess={handleAuthSuccess}
onSuccess={handleGitHubAuthSuccess}
onCancel={onSkip}
/>
</div>
</>
);
case 'claude-auth':
return (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Connect to Claude AI
</DialogTitle>
<DialogDescription>
Auto Claude uses Claude AI for intelligent features like Roadmap generation, Task automation, and Ideation.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<ClaudeOAuthFlow
onSuccess={handleClaudeAuthSuccess}
onCancel={onSkip}
/>
</div>
@@ -372,20 +404,27 @@ export function GitHubSetupModal({
// Progress indicator
const renderProgress = () => {
const steps: { key: SetupStep; label: string }[] = [
{ key: 'auth', label: 'Connect' },
{ key: 'branch', label: 'Configure' },
const steps: { label: string }[] = [
{ label: 'Authenticate' },
{ label: 'Configure' },
];
// Don't show progress on complete step
if (step === 'complete') return null;
const currentIndex = step === 'auth' ? 0 : step === 'repo' ? 0 : 1;
// Map steps to progress indices
// Auth steps (github-auth, claude-auth, repo) = 0
// Config steps (branch) = 1
const currentIndex =
step === 'github-auth' ? 0 :
step === 'claude-auth' ? 0 :
step === 'repo' ? 0 :
1;
return (
<div className="flex items-center justify-center gap-2 mb-4">
{steps.map((s, index) => (
<div key={s.key} className="flex items-center">
<div key={index} className="flex items-center">
<div
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
index < currentIndex
@@ -238,6 +238,7 @@ export function ProjectSettings({ project, open, onOpenChange }: ProjectSettings
onUpdateConfig={updateEnvConfig}
gitHubConnectionStatus={gitHubConnectionStatus}
isCheckingGitHub={isCheckingGitHub}
projectName={project.name}
/>
<Separator />
@@ -18,12 +18,15 @@ import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Card, CardContent } from '../ui/card';
import { Switch } from '../ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from '../ui/tooltip';
import { useSettingsStore } from '../../stores/settings-store';
import type { GraphitiProviderType } from '../../../shared/types';
import type { AppSettings } from '../../../shared/types/settings';
interface GraphitiStepProps {
onNext: () => void;
@@ -34,12 +37,52 @@ interface GraphitiStepProps {
interface GraphitiConfig {
enabled: boolean;
falkorDbUri: string;
openAiApiKey: string;
llmProvider: GraphitiProviderType;
apiKey: string;
ollamaBaseUrl: string; // For Ollama provider (no API key needed)
}
// Provider display info
const PROVIDER_INFO: Record<GraphitiProviderType, {
name: string;
placeholder: string;
link: string;
requiresApiKey: boolean;
description?: string;
}> = {
openai: { name: 'OpenAI', placeholder: 'sk-...', link: 'https://platform.openai.com/api-keys', requiresApiKey: true },
anthropic: { name: 'Anthropic', placeholder: 'sk-ant-...', link: 'https://console.anthropic.com/settings/keys', requiresApiKey: true },
google: { name: 'Google (Gemini)', placeholder: 'AIza...', link: 'https://aistudio.google.com/apikey', requiresApiKey: true },
groq: { name: 'Groq', placeholder: 'gsk_...', link: 'https://console.groq.com/keys', requiresApiKey: true },
ollama: {
name: 'Ollama',
placeholder: 'http://localhost:11434',
link: 'https://ollama.ai',
requiresApiKey: false,
description: 'Local LLM - no API key required'
},
};
// Helper to get the saved API key for a provider from settings
function getApiKeyForProvider(provider: GraphitiProviderType, settings: AppSettings): string {
switch (provider) {
case 'openai': return settings.globalOpenAIApiKey || '';
case 'anthropic': return settings.globalAnthropicApiKey || '';
case 'google': return settings.globalGoogleApiKey || '';
case 'groq': return settings.globalGroqApiKey || '';
case 'ollama': return ''; // Ollama doesn't need an API key
default: return '';
}
}
// Helper to get the saved Ollama base URL from settings
function getOllamaBaseUrl(settings: AppSettings): string {
return settings.ollamaBaseUrl || 'http://localhost:11434';
}
interface ValidationStatus {
falkordb: { tested: boolean; success: boolean; message: string } | null;
openai: { tested: boolean; success: boolean; message: string } | null;
llm: { tested: boolean; success: boolean; message: string } | null;
}
/**
@@ -49,10 +92,14 @@ interface ValidationStatus {
*/
export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
const { settings, updateSettings } = useSettingsStore();
// Load saved provider preference, defaulting to 'openai'
const savedProvider = settings.graphitiLlmProvider || 'openai';
const [config, setConfig] = useState<GraphitiConfig>({
enabled: false,
falkorDbUri: 'bolt://localhost:6379', // Standard FalkorDB port, will be auto-detected from Docker
openAiApiKey: settings.globalOpenAIApiKey || ''
llmProvider: savedProvider,
apiKey: getApiKeyForProvider(savedProvider, settings),
ollamaBaseUrl: getOllamaBaseUrl(settings)
});
const [showApiKey, setShowApiKey] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -63,7 +110,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
const [isValidating, setIsValidating] = useState(false);
const [validationStatus, setValidationStatus] = useState<ValidationStatus>({
falkordb: null,
openai: null
llm: null
});
// Check Docker/Infrastructure availability on mount
@@ -99,23 +146,51 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
setError(null);
setSuccess(false);
// Reset validation status when toggling
setValidationStatus({ falkordb: null, openai: null });
setValidationStatus({ falkordb: null, llm: null });
};
const handleProviderChange = (provider: GraphitiProviderType) => {
// Load saved API key or base URL for the selected provider
const savedKey = getApiKeyForProvider(provider, settings);
const savedOllamaUrl = getOllamaBaseUrl(settings);
setConfig(prev => ({
...prev,
llmProvider: provider,
apiKey: savedKey,
ollamaBaseUrl: savedOllamaUrl
}));
setValidationStatus(prev => ({ ...prev, llm: null }));
setError(null);
};
const handleTestConnection = async () => {
if (!config.openAiApiKey.trim()) {
setError('Please enter an OpenAI API key to test the connection');
const providerName = PROVIDER_INFO[config.llmProvider].name;
const providerInfo = PROVIDER_INFO[config.llmProvider];
// Validate input based on provider type
if (providerInfo.requiresApiKey && !config.apiKey.trim()) {
setError(`Please enter a ${providerName} API key to test the connection`);
return;
}
if (config.llmProvider === 'ollama' && !config.ollamaBaseUrl.trim()) {
setError('Please enter the Ollama server URL to test the connection');
return;
}
setIsValidating(true);
setError(null);
setValidationStatus({ falkordb: null, openai: null });
setValidationStatus({ falkordb: null, llm: null });
try {
// For now, we still use the OpenAI test endpoint, but pass the provider info
// TODO: Add provider-specific validation endpoints
// For Ollama, pass the base URL instead of API key
const testCredential = config.llmProvider === 'ollama'
? config.ollamaBaseUrl.trim()
: config.apiKey.trim();
const result = await window.electronAPI.testGraphitiConnection(
config.falkorDbUri,
config.openAiApiKey.trim()
testCredential
);
if (result?.success && result?.data) {
@@ -125,7 +200,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
success: result.data.falkordb.success,
message: result.data.falkordb.message
},
openai: {
llm: {
tested: true,
success: result.data.openai.success,
message: result.data.openai.message
@@ -138,7 +213,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
errors.push(`FalkorDB: ${result.data.falkordb.message}`);
}
if (!result.data.openai.success) {
errors.push(`OpenAI: ${result.data.openai.message}`);
errors.push(`${providerName}: ${result.data.openai.message}`);
}
if (errors.length > 0) {
setError(errors.join('\n'));
@@ -161,8 +236,16 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
return;
}
if (!config.openAiApiKey.trim()) {
setError('OpenAI API key is required for Graphiti embeddings');
const providerName = PROVIDER_INFO[config.llmProvider].name;
const providerInfo = PROVIDER_INFO[config.llmProvider];
// Validate input based on provider type
if (providerInfo.requiresApiKey && !config.apiKey.trim()) {
setError(`${providerName} API key is required for Graphiti`);
return;
}
if (config.llmProvider === 'ollama' && !config.ollamaBaseUrl.trim()) {
setError('Ollama server URL is required for Graphiti');
return;
}
@@ -170,14 +253,41 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
setError(null);
try {
// Save OpenAI API key to global settings
const result = await window.electronAPI.saveSettings({
globalOpenAIApiKey: config.openAiApiKey.trim()
});
// Build settings update based on selected provider
const settingsUpdate: Record<string, string> = {
graphitiLlmProvider: config.llmProvider,
};
// Save the API key or base URL for the selected provider
if (config.llmProvider === 'openai') {
settingsUpdate.globalOpenAIApiKey = config.apiKey.trim();
} else if (config.llmProvider === 'anthropic') {
settingsUpdate.globalAnthropicApiKey = config.apiKey.trim();
} else if (config.llmProvider === 'google') {
settingsUpdate.globalGoogleApiKey = config.apiKey.trim();
} else if (config.llmProvider === 'groq') {
settingsUpdate.globalGroqApiKey = config.apiKey.trim();
} else if (config.llmProvider === 'ollama') {
settingsUpdate.ollamaBaseUrl = config.ollamaBaseUrl.trim();
}
const result = await window.electronAPI.saveSettings(settingsUpdate);
if (result?.success) {
// Update local settings store
updateSettings({ globalOpenAIApiKey: config.openAiApiKey.trim() });
// Update local settings store for all providers
const storeUpdate: Record<string, string> = {};
if (config.llmProvider === 'openai') {
storeUpdate.globalOpenAIApiKey = config.apiKey.trim();
} else if (config.llmProvider === 'anthropic') {
storeUpdate.globalAnthropicApiKey = config.apiKey.trim();
} else if (config.llmProvider === 'google') {
storeUpdate.globalGoogleApiKey = config.apiKey.trim();
} else if (config.llmProvider === 'groq') {
storeUpdate.globalGroqApiKey = config.apiKey.trim();
} else if (config.llmProvider === 'ollama') {
storeUpdate.ollamaBaseUrl = config.ollamaBaseUrl.trim();
}
updateSettings(storeUpdate);
// Proceed to next step immediately after successful save
onNext();
} else {
@@ -344,7 +454,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
Enable Graphiti Memory
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Requires FalkorDB (Docker) and OpenAI API key
Requires FalkorDB (Docker) and an LLM provider (API key or local Ollama)
</p>
</div>
</div>
@@ -360,6 +470,30 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
{/* Configuration fields (shown when enabled) */}
{config.enabled && (
<div className="space-y-4 animate-in slide-in-from-top-2 duration-200">
{/* LLM Provider Selection */}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">LLM Provider</Label>
<p className="text-xs text-muted-foreground">
Select the AI provider for graph operations
</p>
<Select
value={config.llmProvider}
onValueChange={(value) => handleProviderChange(value as GraphitiProviderType)}
disabled={isSaving || isValidating}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI (GPT)</SelectItem>
<SelectItem value="anthropic">Anthropic (Claude)</SelectItem>
<SelectItem value="google">Google (Gemini)</SelectItem>
<SelectItem value="groq">Groq (Llama)</SelectItem>
<SelectItem value="ollama">Ollama (Local)</SelectItem>
</SelectContent>
</Select>
</div>
{/* FalkorDB URI */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -399,76 +533,124 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
</p>
</div>
{/* OpenAI API Key */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="openai-key" className="text-sm font-medium text-foreground">
OpenAI API Key
</Label>
{validationStatus.openai && (
<div className="flex items-center gap-1.5">
{validationStatus.openai.success ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<XCircle className="h-4 w-4 text-destructive" />
)}
<span className={`text-xs ${validationStatus.openai.success ? 'text-success' : 'text-destructive'}`}>
{validationStatus.openai.success ? 'Valid' : 'Invalid'}
</span>
</div>
)}
</div>
<div className="relative">
{/* Dynamic credential field based on provider */}
{config.llmProvider === 'ollama' ? (
/* Ollama Base URL field */
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="ollama-url" className="text-sm font-medium text-foreground">
Ollama Server URL
</Label>
{validationStatus.llm && (
<div className="flex items-center gap-1.5">
{validationStatus.llm.success ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<XCircle className="h-4 w-4 text-destructive" />
)}
<span className={`text-xs ${validationStatus.llm.success ? 'text-success' : 'text-destructive'}`}>
{validationStatus.llm.success ? 'Connected' : 'Failed'}
</span>
</div>
)}
</div>
<Input
id="openai-key"
type={showApiKey ? 'text' : 'password'}
value={config.openAiApiKey}
id="ollama-url"
type="text"
value={config.ollamaBaseUrl}
onChange={(e) => {
setConfig(prev => ({ ...prev, openAiApiKey: e.target.value }));
setValidationStatus(prev => ({ ...prev, openai: null }));
setConfig(prev => ({ ...prev, ollamaBaseUrl: e.target.value }));
setValidationStatus(prev => ({ ...prev, llm: null }));
}}
placeholder="sk-..."
className="pr-10 font-mono text-sm"
placeholder="http://localhost:11434"
className="font-mono text-sm"
disabled={isSaving || isValidating}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{showApiKey ? 'Hide API key' : 'Show API key'}
</TooltipContent>
</Tooltip>
<p className="text-xs text-muted-foreground">
No API key required. Make sure{' '}
<a
href="https://ollama.ai"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80"
>
Ollama
</a>
{' '}is running locally.
</p>
</div>
<p className="text-xs text-muted-foreground">
Required for generating embeddings. Get your key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80"
>
OpenAI
</a>
</p>
</div>
) : (
/* API Key field for other providers */
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="api-key" className="text-sm font-medium text-foreground">
{PROVIDER_INFO[config.llmProvider].name} API Key
</Label>
{validationStatus.llm && (
<div className="flex items-center gap-1.5">
{validationStatus.llm.success ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<XCircle className="h-4 w-4 text-destructive" />
)}
<span className={`text-xs ${validationStatus.llm.success ? 'text-success' : 'text-destructive'}`}>
{validationStatus.llm.success ? 'Valid' : 'Invalid'}
</span>
</div>
)}
</div>
<div className="relative">
<Input
id="api-key"
type={showApiKey ? 'text' : 'password'}
value={config.apiKey}
onChange={(e) => {
setConfig(prev => ({ ...prev, apiKey: e.target.value }));
setValidationStatus(prev => ({ ...prev, llm: null }));
}}
placeholder={PROVIDER_INFO[config.llmProvider].placeholder}
className="pr-10 font-mono text-sm"
disabled={isSaving || isValidating}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{showApiKey ? 'Hide API key' : 'Show API key'}
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Required for graph operations. Get your key from{' '}
<a
href={PROVIDER_INFO[config.llmProvider].link}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80"
>
{PROVIDER_INFO[config.llmProvider].name}
</a>
</p>
</div>
)}
{/* Test Connection Button */}
<div className="pt-2">
<Button
variant="outline"
onClick={handleTestConnection}
disabled={!config.openAiApiKey.trim() || isValidating || isSaving}
disabled={(config.llmProvider === 'ollama' ? !config.ollamaBaseUrl.trim() : !config.apiKey.trim()) || isValidating || isSaving}
className="w-full"
>
{isValidating ? (
@@ -483,11 +665,21 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
</>
)}
</Button>
{validationStatus.falkordb?.success && validationStatus.openai?.success && (
{validationStatus.falkordb?.success && validationStatus.llm?.success && (
<p className="text-xs text-success text-center mt-2">
All connections validated successfully!
</p>
)}
{config.llmProvider !== 'openai' && config.llmProvider !== 'ollama' && (
<p className="text-xs text-muted-foreground text-center mt-2">
Note: API key validation currently only fully supports OpenAI. Your {PROVIDER_INFO[config.llmProvider].name} key will be saved and used at runtime.
</p>
)}
{config.llmProvider === 'ollama' && (
<p className="text-xs text-muted-foreground text-center mt-2">
Note: Ollama connection will be tested by checking if the server is reachable.
</p>
)}
</div>
</div>
)}
@@ -515,7 +707,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
</Button>
<Button
onClick={handleContinue}
disabled={isCheckingDocker || (config.enabled && !config.openAiApiKey.trim() && !success) || isSaving || isValidating}
disabled={isCheckingDocker || (config.enabled && !success && (config.llmProvider === 'ollama' ? !config.ollamaBaseUrl.trim() : !config.apiKey.trim())) || isSaving || isValidating}
>
{isSaving ? (
<>
@@ -0,0 +1,243 @@
import { useState, useEffect, useRef } from 'react';
import {
Key,
Loader2,
CheckCircle2,
AlertCircle,
Info,
Sparkles
} from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent } from '../ui/card';
interface ClaudeOAuthFlowProps {
onSuccess: () => void;
onCancel?: () => void;
}
/**
* Claude OAuth flow component for setup wizard
* Guides users through authenticating with Claude using claude setup-token
*/
export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
const [status, setStatus] = useState<'ready' | 'authenticating' | 'success' | 'error'>('ready');
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState<string | undefined>();
// Track if we've already started auth to prevent double-execution
const hasStartedRef = useRef(false);
const listenerSetupRef = useRef(false);
// Listen for OAuth token detection
useEffect(() => {
if (listenerSetupRef.current) return;
listenerSetupRef.current = true;
const unsubscribe = window.electronAPI.onTerminalOAuthToken((info) => {
console.warn('[ClaudeOAuth] Token event received:', {
success: info.success,
hasEmail: !!info.email,
profileId: info.profileId
});
if (info.success) {
setEmail(info.email);
setStatus('success');
// Auto-advance after a short delay to show success message
setTimeout(() => {
onSuccess();
}, 1500);
} else {
setError(info.message || 'Failed to save OAuth token');
setStatus('error');
}
});
return () => {
if (unsubscribe) {
unsubscribe();
}
};
}, [onSuccess]);
const handleStartAuth = async () => {
if (hasStartedRef.current) {
console.warn('[ClaudeOAuth] Auth already started, ignoring duplicate call');
return;
}
hasStartedRef.current = true;
console.warn('[ClaudeOAuth] Starting Claude authentication');
setStatus('authenticating');
setError(null);
try {
// Get the active profile ID
const profilesResult = await window.electronAPI.getClaudeProfiles();
if (!profilesResult.success || !profilesResult.data) {
throw new Error('Failed to get Claude profiles');
}
const activeProfileId = profilesResult.data.activeProfileId;
console.warn('[ClaudeOAuth] Initializing profile:', activeProfileId);
// Initialize the profile - this opens a terminal and runs 'claude setup-token'
const result = await window.electronAPI.initializeClaudeProfile(activeProfileId);
if (!result.success) {
throw new Error(result.error || 'Failed to start authentication');
}
console.warn('[ClaudeOAuth] Authentication started, waiting for token...');
// Status will be updated by the event listener when token is detected
} catch (err) {
console.error('[ClaudeOAuth] Authentication failed:', err);
setError(err instanceof Error ? err.message : 'Authentication failed');
setStatus('error');
hasStartedRef.current = false;
}
};
const handleRetry = () => {
hasStartedRef.current = false;
setStatus('ready');
setError(null);
};
return (
<div className="space-y-4">
{/* Ready to authenticate */}
{status === 'ready' && (
<div className="space-y-4">
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-5">
<div className="flex items-start gap-4">
<Key className="h-6 w-6 text-info shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<h3 className="text-lg font-medium text-foreground">
Authenticate with Claude
</h3>
<p className="text-sm text-muted-foreground">
Auto Claude requires Claude AI authentication for AI-powered features like
Roadmap generation, Task automation, and Ideation.
</p>
<p className="text-sm text-muted-foreground">
This will open a browser window to authenticate with your Claude account.
Your credentials are stored securely and are valid for 1 year.
</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-center">
<Button onClick={handleStartAuth} size="lg" className="gap-2">
<Key className="h-5 w-5" />
Authenticate with Claude
</Button>
</div>
</div>
)}
{/* Authenticating */}
{status === 'authenticating' && (
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center gap-4">
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-medium text-foreground">
Authenticating...
</h3>
<p className="text-sm text-muted-foreground mt-1">
A terminal window has opened. Please complete the authentication in your browser.
</p>
</div>
</div>
<div className="rounded-lg bg-background/50 p-3 space-y-2">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">What's happening:</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>A terminal opened and ran <code className="px-1 bg-muted rounded">claude setup-token</code></li>
<li>Your browser should open to authenticate with Claude</li>
<li>Complete the OAuth flow in your browser</li>
<li>The terminal will display your token (starts with sk-ant-oat01-...)</li>
<li>Auto Claude will automatically detect and save it</li>
</ol>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Success */}
{status === 'success' && (
<Card className="border border-success/30 bg-success/10">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="h-6 w-6 text-success shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-lg font-medium text-success">
Successfully Authenticated!
</h3>
<p className="text-sm text-success/80 mt-1">
{email ? `Connected as ${email}` : 'Your Claude credentials have been saved'}
</p>
<div className="flex items-center gap-2 mt-3 text-xs text-success/70">
<Sparkles className="h-3 w-3" />
<span>You can now use all Auto Claude AI features</span>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Error */}
{status === 'error' && error && (
<div className="space-y-4">
<Card className="border border-destructive/30 bg-destructive/10">
<CardContent className="p-5">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-lg font-medium text-destructive">
Authentication Failed
</h3>
<p className="text-sm text-destructive/80 mt-1">{error}</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-center gap-3">
<Button onClick={handleRetry} variant="outline">
Retry
</Button>
{onCancel && (
<Button onClick={onCancel} variant="ghost">
Cancel
</Button>
)}
</div>
</div>
)}
{/* Cancel button for ready/authenticating states */}
{(status === 'ready' || status === 'authenticating') && onCancel && (
<div className="flex justify-center pt-2">
<Button onClick={onCancel} variant="ghost" size="sm">
Skip for now
</Button>
</div>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Github, RefreshCw, KeyRound } from 'lucide-react';
import { Github, RefreshCw, KeyRound, Info } from 'lucide-react';
import { CollapsibleSection } from './CollapsibleSection';
import { StatusBadge } from './StatusBadge';
import { PasswordInput } from './PasswordInput';
@@ -19,6 +19,7 @@ interface GitHubIntegrationSectionProps {
onUpdateConfig: (updates: Partial<ProjectEnvConfig>) => void;
gitHubConnectionStatus: GitHubSyncStatus | null;
isCheckingGitHub: boolean;
projectName?: string;
}
export function GitHubIntegrationSection({
@@ -28,6 +29,7 @@ export function GitHubIntegrationSection({
onUpdateConfig,
gitHubConnectionStatus,
isCheckingGitHub,
projectName,
}: GitHubIntegrationSectionProps) {
const [showOAuthFlow, setShowOAuthFlow] = useState(false);
@@ -48,6 +50,22 @@ export function GitHubIntegrationSection({
onToggle={onToggle}
badge={badge}
>
{/* Project-Specific Configuration Notice */}
{projectName && (
<div className="rounded-lg border border-info/30 bg-info/5 p-3 mb-4">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-info mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">Project-Specific Configuration</p>
<p className="text-xs text-muted-foreground mt-1">
This GitHub repository is configured only for <span className="font-semibold text-foreground">{projectName}</span>.
Each project can have its own GitHub repository.
</p>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="font-normal text-foreground">Enable GitHub Issues</Label>
@@ -205,17 +205,26 @@ export async function initializeProject(
const store = useProjectStore.getState();
try {
console.log('[ProjectStore] initializeProject called for:', projectId);
const result = await window.electronAPI.initializeProject(projectId);
console.log('[ProjectStore] IPC result:', result);
if (result.success && result.data) {
console.log('[ProjectStore] IPC succeeded, result.data:', result.data);
// Update the project's autoBuildPath in local state
if (result.data.success) {
console.log('[ProjectStore] Updating project autoBuildPath to .auto-claude');
store.updateProject(projectId, { autoBuildPath: '.auto-claude' });
} else {
console.log('[ProjectStore] result.data.success is false, not updating project');
}
return result.data;
}
console.log('[ProjectStore] IPC failed or no data, setting error');
store.setError(result.error || 'Failed to initialize project');
return null;
} catch (error) {
console.error('[ProjectStore] Exception during initializeProject:', error);
store.setError(error instanceof Error ? error.message : 'Unknown error');
return null;
}
+8 -2
View File
@@ -186,8 +186,8 @@ export interface GraphitiConnectionTestResult {
}
// Graphiti Provider Types (Memory System V2)
export type GraphitiProviderType = 'openai' | 'anthropic' | 'google' | 'groq';
export type GraphitiEmbeddingProvider = 'openai' | 'voyage' | 'google' | 'huggingface';
export type GraphitiProviderType = 'openai' | 'anthropic' | 'google' | 'groq' | 'ollama';
export type GraphitiEmbeddingProvider = 'openai' | 'voyage' | 'google' | 'huggingface' | 'ollama';
export interface GraphitiProviderConfig {
// LLM Provider
@@ -205,6 +205,12 @@ export interface GraphitiProviderConfig {
groqApiKey?: string;
voyageApiKey?: string;
// Ollama-specific config (local LLM, no API key required)
ollamaBaseUrl?: string; // Default: http://localhost:11434
ollamaLlmModel?: string;
ollamaEmbeddingModel?: string;
ollamaEmbeddingDim?: number;
// FalkorDB connection (required for all providers)
falkorDbHost?: string;
falkorDbPort?: number;
@@ -54,6 +54,12 @@ export interface AppSettings {
// Global API keys (used as defaults for all projects)
globalClaudeOAuthToken?: string;
globalOpenAIApiKey?: string;
globalAnthropicApiKey?: string;
globalGoogleApiKey?: string;
globalGroqApiKey?: string;
// Graphiti LLM provider settings
graphitiLlmProvider?: 'openai' | 'anthropic' | 'google' | 'groq' | 'ollama';
ollamaBaseUrl?: string;
// Onboarding wizard completion state
onboardingCompleted?: boolean;
// Selected agent profile for preset model/thinking configurations
+2
View File
@@ -351,8 +351,10 @@ export interface MergeStats {
export interface WorktreeMergeResult {
success: boolean;
message: string;
merged?: boolean;
conflictFiles?: string[];
staged?: boolean;
alreadyStaged?: boolean;
projectPath?: string;
// New conflict info from smart merge
conflicts?: MergeConflict[];
+9 -1
View File
@@ -91,7 +91,12 @@ def handle_qa_command(
if not validate_environment(spec_dir):
sys.exit(1)
if not should_run_qa(spec_dir):
# 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:
@@ -100,6 +105,9 @@ def handle_qa_command(
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(
+7
View File
@@ -144,24 +144,31 @@ try:
except ImportError:
def debug(*args, **kwargs):
"""Fallback debug function when debug module is not available."""
pass
def debug_detailed(*args, **kwargs):
"""Fallback debug_detailed function when debug module is not available."""
pass
def debug_verbose(*args, **kwargs):
"""Fallback debug_verbose function when debug module is not available."""
pass
def debug_success(*args, **kwargs):
"""Fallback debug_success function when debug module is not available."""
pass
def debug_error(*args, **kwargs):
"""Fallback debug_error function when debug module is not available."""
pass
def debug_section(*args, **kwargs):
"""Fallback debug_section function when debug module is not available."""
pass
def is_debug_enabled():
"""Fallback is_debug_enabled function when debug module is not available."""
return False
+50 -2
View File
@@ -108,12 +108,60 @@ async def run_qa_validation_loop(
print(f" Progress: {completed}/{total} subtasks completed")
return False
# Check if already approved
if is_qa_approved(spec_dir):
# Check if there's pending human feedback that needs to be processed
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
has_human_feedback = fix_request_file.exists()
# Check if already approved - but if there's human feedback, we need to process it first
if is_qa_approved(spec_dir) and not has_human_feedback:
debug_success("qa_loop", "Build already approved by QA")
print("\n✅ Build already approved by QA.")
return True
# If there's human feedback, we need to run the fixer first before re-validating
if has_human_feedback:
debug(
"qa_loop",
"Human feedback detected - will run fixer first",
fix_request_file=str(fix_request_file),
)
print("\n📝 Human feedback detected. Running QA Fixer first...")
# Get model for fixer
qa_model = get_phase_model(spec_dir, "qa", model)
fixer_thinking_budget = get_thinking_budget("medium")
fix_client = create_client(
project_dir,
spec_dir,
qa_model,
agent_type="qa_fixer",
max_thinking_tokens=fixer_thinking_budget,
)
async with fix_client:
fix_status, fix_response = await run_qa_fixer_session(
fix_client,
spec_dir,
0,
False, # iteration 0 for human feedback
)
if fix_status == "error":
debug_error("qa_loop", f"Fixer error: {fix_response[:200]}")
print(f"\n❌ Fixer encountered error: {fix_response}")
return False
debug_success("qa_loop", "Human feedback fixes applied")
print("\n✅ Fixes applied based on human feedback. Running QA validation...")
# Remove the fix request file after processing
try:
fix_request_file.unlink()
debug("qa_loop", "Removed processed QA_FIX_REQUEST.md")
except OSError:
pass # Ignore if file removal fails
# Check for no-test projects
if is_no_test_project(spec_dir, project_dir):
print("\n⚠️ No test framework detected in project.")
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* Version Bump Script
*
* Automatically bumps the version in package.json and creates a git tag.
* This ensures version consistency between package.json and git tags.
*
* Usage:
* node scripts/bump-version.js <major|minor|patch|x.y.z>
*
* Examples:
* node scripts/bump-version.js patch # 2.5.5 -> 2.5.6
* node scripts/bump-version.js minor # 2.5.5 -> 2.6.0
* node scripts/bump-version.js major # 2.5.5 -> 3.0.0
* node scripts/bump-version.js 2.6.0 # Set to specific version
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Colors for terminal output
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
};
function log(message, color = colors.reset) {
console.log(`${color}${message}${colors.reset}`);
}
function error(message) {
log(`❌ Error: ${message}`, colors.red);
process.exit(1);
}
function success(message) {
log(`${message}`, colors.green);
}
function info(message) {
log(`${message}`, colors.cyan);
}
function warning(message) {
log(`⚠️ ${message}`, colors.yellow);
}
// Parse semver version
function parseVersion(version) {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
error(`Invalid version format: ${version}. Expected format: x.y.z`);
}
return {
major: parseInt(match[1]),
minor: parseInt(match[2]),
patch: parseInt(match[3]),
};
}
// Bump version based on type
function bumpVersion(currentVersion, bumpType) {
const version = parseVersion(currentVersion);
switch (bumpType) {
case 'major':
return `${version.major + 1}.0.0`;
case 'minor':
return `${version.major}.${version.minor + 1}.0`;
case 'patch':
return `${version.major}.${version.minor}.${version.patch + 1}`;
default:
// Assume it's a specific version
parseVersion(bumpType); // Validate format
return bumpType;
}
}
// Execute shell command
function exec(command, options = {}) {
try {
return execSync(command, { encoding: 'utf8', stdio: 'pipe', ...options }).trim();
} catch (err) {
error(`Command failed: ${command}\n${err.message}`);
}
}
// Check if git working directory is clean
function checkGitStatus() {
const status = exec('git status --porcelain');
if (status) {
error('Git working directory is not clean. Please commit or stash changes first.');
}
}
// Update package.json version
function updatePackageJson(newVersion) {
const packagePath = path.join(__dirname, '..', 'auto-claude-ui', 'package.json');
if (!fs.existsSync(packagePath)) {
error(`package.json not found at ${packagePath}`);
}
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const oldVersion = packageJson.version;
packageJson.version = newVersion;
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2) + '\n');
return { oldVersion, packagePath };
}
// Main function
function main() {
const bumpType = process.argv[2];
if (!bumpType) {
error('Please specify version bump type or version number.\n' +
'Usage: node scripts/bump-version.js <major|minor|patch|x.y.z>');
}
log('\n🚀 Auto Claude Version Bump\n', colors.cyan);
// 1. Check git status
info('Checking git status...');
checkGitStatus();
success('Git working directory is clean');
// 2. Read current version
const packagePath = path.join(__dirname, '..', 'auto-claude-ui', 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const currentVersion = packageJson.version;
info(`Current version: ${currentVersion}`);
// 3. Calculate new version
const newVersion = bumpVersion(currentVersion, bumpType);
info(`New version: ${newVersion}`);
if (currentVersion === newVersion) {
error('New version is the same as current version');
}
// 4. Update package.json
info('Updating package.json...');
updatePackageJson(newVersion);
success('Updated package.json');
// 5. Create git commit
info('Creating git commit...');
exec('git add auto-claude-ui/package.json');
exec(`git commit -m "chore: bump version to ${newVersion}"`);
success(`Created commit: "chore: bump version to ${newVersion}"`);
// 6. Create git tag
info('Creating git tag...');
exec(`git tag -a v${newVersion} -m "Release v${newVersion}"`);
success(`Created tag: v${newVersion}`);
// 7. Instructions
log('\n📋 Next steps:', colors.yellow);
log(` 1. Review the changes: git log -1`, colors.yellow);
log(` 2. Push the commit: git push origin <branch-name>`, colors.yellow);
log(` 3. Push the tag: git push origin v${newVersion}`, colors.yellow);
log(` 4. Create a GitHub release from the tag\n`, colors.yellow);
warning('Note: The commit and tag have been created locally but NOT pushed.');
warning('Please review and push manually when ready.');
log('\n✨ Version bump complete!\n', colors.green);
}
// Run
main();