diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a2b98273..8c8e1a7e 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -7,7 +7,8 @@ # 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 1: Run `claude setup-token` to save token to system keychain (recommended) +# (macOS: Keychain, Windows: Credential Manager, Linux: secret-service) # Option 2: Set the token explicitly: # CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here # diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index ae0c6bae..be105e1f 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -34,20 +34,30 @@ SDK_ENV_VARS = [ def get_token_from_keychain() -> str | None: """ - Get authentication token from macOS Keychain. + Get authentication token from system credential store. - Reads Claude Code credentials from macOS Keychain and extracts the OAuth token. - Only works on macOS (Darwin platform). + Reads Claude Code credentials from: + - macOS: Keychain + - Windows: Credential Manager + - Linux: Not yet supported (use env var) Returns: - Token string if found in Keychain, None otherwise + Token string if found, None otherwise """ - # Only attempt on macOS - if platform.system() != "Darwin": + system = platform.system() + + if system == "Darwin": + return _get_token_from_macos_keychain() + elif system == "Windows": + return _get_token_from_windows_credential_files() + else: + # Linux: secret-service not yet implemented return None + +def _get_token_from_macos_keychain() -> str | None: + """Get token from macOS Keychain.""" try: - # Query macOS Keychain for Claude Code credentials result = subprocess.run( [ "/usr/bin/security", @@ -64,14 +74,11 @@ def get_token_from_keychain() -> str | None: if result.returncode != 0: return None - # Parse JSON response credentials_json = result.stdout.strip() if not credentials_json: return None data = json.loads(credentials_json) - - # Extract OAuth token from nested structure token = data.get("claudeAiOauth", {}).get("accessToken") if not token: @@ -84,18 +91,45 @@ def get_token_from_keychain() -> str | None: return token except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception): - # Silently fail - this is a fallback mechanism + return None + + +def _get_token_from_windows_credential_files() -> str | None: + """Get token from Windows credential files. + + Claude Code on Windows stores credentials in ~/.claude/.credentials.json + """ + try: + # Claude Code stores credentials in ~/.claude/.credentials.json + cred_paths = [ + os.path.expandvars(r"%USERPROFILE%\.claude\.credentials.json"), + os.path.expandvars(r"%USERPROFILE%\.claude\credentials.json"), + os.path.expandvars(r"%LOCALAPPDATA%\Claude\credentials.json"), + os.path.expandvars(r"%APPDATA%\Claude\credentials.json"), + ] + + for cred_path in cred_paths: + if os.path.exists(cred_path): + with open(cred_path, encoding="utf-8") as f: + data = json.load(f) + token = data.get("claudeAiOauth", {}).get("accessToken") + if token and token.startswith("sk-ant-oat01-"): + return token + + return None + + except (json.JSONDecodeError, KeyError, FileNotFoundError, Exception): return None def get_auth_token() -> str | None: """ - Get authentication token from environment variables or macOS Keychain. + Get authentication token from environment variables or system credential store. Checks multiple sources in priority order: 1. CLAUDE_CODE_OAUTH_TOKEN (env var) 2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups) - 3. macOS Keychain (if on Darwin platform) + 3. System credential store (macOS Keychain, Windows Credential Manager) NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent silent billing to user's API credits when OAuth is misconfigured. @@ -109,7 +143,7 @@ def get_auth_token() -> str | None: if token: return token - # Fallback to macOS Keychain + # Fallback to system credential store return get_token_from_keychain() @@ -120,9 +154,15 @@ def get_auth_token_source() -> str | None: if os.environ.get(var): return var - # Check if token came from macOS Keychain + # Check if token came from system credential store if get_token_from_keychain(): - return "macOS Keychain" + system = platform.system() + if system == "Darwin": + return "macOS Keychain" + elif system == "Windows": + return "Windows Credential Files" + else: + return "System Credential Store" return None @@ -142,13 +182,22 @@ def require_auth_token() -> str: "Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n" ) # Provide platform-specific guidance - if platform.system() == "Darwin": + system = platform.system() + if system == "Darwin": error_msg += ( "To authenticate:\n" " 1. Run: claude setup-token\n" " 2. The token will be saved to macOS Keychain automatically\n\n" "Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file." ) + elif system == "Windows": + error_msg += ( + "To authenticate:\n" + " 1. Run: claude setup-token\n" + " 2. The token should be saved to Windows Credential Manager\n\n" + "If auto-detection fails, set CLAUDE_CODE_OAUTH_TOKEN in your .env file.\n" + "Check: %LOCALAPPDATA%\\Claude\\credentials.json" + ) else: error_msg += ( "To authenticate:\n" diff --git a/apps/backend/runners/github/runner.py b/apps/backend/runners/github/runner.py index dabfa4d5..02af642e 100644 --- a/apps/backend/runners/github/runner.py +++ b/apps/backend/runners/github/runner.py @@ -46,6 +46,13 @@ import os import sys from pathlib import Path +# Fix Windows console encoding for Unicode output (emojis, special chars) +if sys.platform == "win32": + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + # Add backend to path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) @@ -79,34 +86,70 @@ def print_progress(callback: ProgressCallback) -> None: def get_config(args) -> GitHubRunnerConfig: """Build config from CLI args and environment.""" + import shutil + import subprocess + token = args.token or os.environ.get("GITHUB_TOKEN", "") bot_token = args.bot_token or os.environ.get("GITHUB_BOT_TOKEN") repo = args.repo or os.environ.get("GITHUB_REPO", "") - if not token: + # Find gh CLI - use shutil.which for cross-platform support + gh_path = shutil.which("gh") + if not gh_path and sys.platform == "win32": + # Fallback: check common Windows installation paths + common_paths = [ + r"C:\Program Files\GitHub CLI\gh.exe", + r"C:\Program Files (x86)\GitHub CLI\gh.exe", + os.path.expandvars(r"%LOCALAPPDATA%\Programs\GitHub CLI\gh.exe"), + ] + for path in common_paths: + if os.path.exists(path): + gh_path = path + break + + if os.environ.get("DEBUG"): + print(f"[DEBUG] gh CLI path: {gh_path}", flush=True) + print( + f"[DEBUG] PATH env: {os.environ.get('PATH', 'NOT SET')[:200]}...", + flush=True, + ) + + if not token and gh_path: # Try to get from gh CLI - import subprocess + try: + result = subprocess.run( + [gh_path, "auth", "token"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + token = result.stdout.strip() + except FileNotFoundError: + pass # gh not installed or not in PATH - result = subprocess.run( - ["gh", "auth", "token"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - token = result.stdout.strip() - - if not repo: + if not repo and gh_path: # Try to detect from git remote - import subprocess - - result = subprocess.run( - ["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], - cwd=args.project, - capture_output=True, - text=True, - ) - if result.returncode == 0: - repo = result.stdout.strip() + try: + result = subprocess.run( + [ + gh_path, + "repo", + "view", + "--json", + "nameWithOwner", + "-q", + ".nameWithOwner", + ], + cwd=args.project, + capture_output=True, + text=True, + ) + if result.returncode == 0: + repo = result.stdout.strip() + elif os.environ.get("DEBUG"): + print(f"[DEBUG] gh repo view failed: {result.stderr}", flush=True) + except FileNotFoundError: + pass # gh not installed or not in PATH if not token: print("Error: No GitHub token found. Set GITHUB_TOKEN or run 'gh auth login'") @@ -133,27 +176,42 @@ async def cmd_review_pr(args) -> int: import sys # Force unbuffered output so Electron sees it in real-time - sys.stdout.reconfigure(line_buffering=True) - sys.stderr.reconfigure(line_buffering=True) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(line_buffering=True) - print(f"[DEBUG] Starting PR review for PR #{args.pr_number}", flush=True) - print(f"[DEBUG] Project directory: {args.project}", flush=True) + debug = os.environ.get("DEBUG") + if debug: + print(f"[DEBUG] Starting PR review for PR #{args.pr_number}", flush=True) + print(f"[DEBUG] Project directory: {args.project}", flush=True) + print("[DEBUG] Building config...", flush=True) - print("[DEBUG] Building config...", flush=True) config = get_config(args) - print(f"[DEBUG] Config built: repo={config.repo}, model={config.model}", flush=True) - print("[DEBUG] Creating orchestrator...", flush=True) + if debug: + print( + f"[DEBUG] Config built: repo={config.repo}, model={config.model}", + flush=True, + ) + print("[DEBUG] Creating orchestrator...", flush=True) + orchestrator = GitHubOrchestrator( project_dir=args.project, config=config, progress_callback=print_progress, ) - print("[DEBUG] Orchestrator created", flush=True) - print(f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True) + if debug: + print("[DEBUG] Orchestrator created", flush=True) + print( + f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True + ) + result = await orchestrator.review_pr(args.pr_number) - print(f"[DEBUG] review_pr returned, success={result.success}", flush=True) + + if debug: + print(f"[DEBUG] review_pr returned, success={result.success}", flush=True) if result.success: print(f"\n{'=' * 60}") @@ -182,28 +240,38 @@ async def cmd_followup_review_pr(args) -> int: import sys # Force unbuffered output so Electron sees it in real-time - sys.stdout.reconfigure(line_buffering=True) - sys.stderr.reconfigure(line_buffering=True) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(line_buffering=True) - print(f"[DEBUG] Starting follow-up review for PR #{args.pr_number}", flush=True) - print(f"[DEBUG] Project directory: {args.project}", flush=True) + debug = os.environ.get("DEBUG") + if debug: + print(f"[DEBUG] Starting follow-up review for PR #{args.pr_number}", flush=True) + print(f"[DEBUG] Project directory: {args.project}", flush=True) + print("[DEBUG] Building config...", flush=True) - print("[DEBUG] Building config...", flush=True) config = get_config(args) - print(f"[DEBUG] Config built: repo={config.repo}, model={config.model}", flush=True) - print("[DEBUG] Creating orchestrator...", flush=True) + if debug: + print( + f"[DEBUG] Config built: repo={config.repo}, model={config.model}", + flush=True, + ) + print("[DEBUG] Creating orchestrator...", flush=True) + orchestrator = GitHubOrchestrator( project_dir=args.project, config=config, progress_callback=print_progress, ) - print("[DEBUG] Orchestrator created", flush=True) - print( - f"[DEBUG] Calling orchestrator.followup_review_pr({args.pr_number})...", - flush=True, - ) + if debug: + print("[DEBUG] Orchestrator created", flush=True) + print( + f"[DEBUG] Calling orchestrator.followup_review_pr({args.pr_number})...", + flush=True, + ) try: result = await orchestrator.followup_review_pr(args.pr_number) @@ -211,7 +279,10 @@ async def cmd_followup_review_pr(args) -> int: print(f"\nFollow-up review failed: {e}") return 1 - print(f"[DEBUG] followup_review_pr returned, success={result.success}", flush=True) + if debug: + print( + f"[DEBUG] followup_review_pr returned, success={result.success}", flush=True + ) if result.success: print(f"\n{'=' * 60}") diff --git a/apps/backend/runners/github/services/orchestrator_reviewer.py b/apps/backend/runners/github/services/orchestrator_reviewer.py index 32486977..ce64bbd0 100644 --- a/apps/backend/runners/github/services/orchestrator_reviewer.py +++ b/apps/backend/runners/github/services/orchestrator_reviewer.py @@ -628,7 +628,7 @@ class OrchestratorReviewer: ) if prompt_file.exists(): - base_prompt = prompt_file.read_text() + base_prompt = prompt_file.read_text(encoding="utf-8") else: logger.warning("Orchestrator prompt not found!") base_prompt = "You are a PR reviewer. Review the provided PR." diff --git a/apps/backend/runners/github/services/pr_review_engine.py b/apps/backend/runners/github/services/pr_review_engine.py index 38228d75..022834c6 100644 --- a/apps/backend/runners/github/services/pr_review_engine.py +++ b/apps/backend/runners/github/services/pr_review_engine.py @@ -476,7 +476,7 @@ class PRReviewEngine: / "pr_structural.md" ) if prompt_file.exists(): - prompt = prompt_file.read_text() + prompt = prompt_file.read_text(encoding="utf-8") else: prompt = self.prompt_manager.get_review_pass_prompt(ReviewPass.STRUCTURAL) @@ -527,7 +527,7 @@ class PRReviewEngine: / "pr_ai_triage.md" ) if prompt_file.exists(): - prompt = prompt_file.read_text() + prompt = prompt_file.read_text(encoding="utf-8") else: prompt = self.prompt_manager.get_review_pass_prompt( ReviewPass.AI_COMMENT_TRIAGE diff --git a/apps/backend/runners/github/services/prompt_manager.py b/apps/backend/runners/github/services/prompt_manager.py index 643528a5..da1c94ec 100644 --- a/apps/backend/runners/github/services/prompt_manager.py +++ b/apps/backend/runners/github/services/prompt_manager.py @@ -298,7 +298,7 @@ Output JSON array: """Get the main PR review prompt.""" prompt_file = self.prompts_dir / "pr_reviewer.md" if prompt_file.exists(): - return prompt_file.read_text() + return prompt_file.read_text(encoding="utf-8") return self._get_default_pr_review_prompt() def _get_default_pr_review_prompt(self) -> str: @@ -338,7 +338,7 @@ Be specific and actionable. Focus on significant issues, not nitpicks. """Get the follow-up PR review prompt.""" prompt_file = self.prompts_dir / "pr_followup.md" if prompt_file.exists(): - return prompt_file.read_text() + return prompt_file.read_text(encoding="utf-8") return self._get_default_followup_review_prompt() def _get_default_followup_review_prompt(self) -> str: @@ -380,7 +380,7 @@ Output JSON: """Get the issue triage prompt.""" prompt_file = self.prompts_dir / "issue_triager.md" if prompt_file.exists(): - return prompt_file.read_text() + return prompt_file.read_text(encoding="utf-8") return self._get_default_triage_prompt() def _get_default_triage_prompt(self) -> str: diff --git a/apps/backend/runners/github/services/review_tools.py b/apps/backend/runners/github/services/review_tools.py index 84e3ba9b..d36b0fd9 100644 --- a/apps/backend/runners/github/services/review_tools.py +++ b/apps/backend/runners/github/services/review_tools.py @@ -136,7 +136,7 @@ async def spawn_security_review( / "pr_security_agent.md" ) if prompt_file.exists(): - base_prompt = prompt_file.read_text() + base_prompt = prompt_file.read_text(encoding="utf-8") else: logger.warning("Security agent prompt not found, using fallback") base_prompt = _get_fallback_security_prompt() @@ -222,7 +222,7 @@ async def spawn_quality_review( / "pr_quality_agent.md" ) if prompt_file.exists(): - base_prompt = prompt_file.read_text() + base_prompt = prompt_file.read_text(encoding="utf-8") else: logger.warning("Quality agent prompt not found, using fallback") base_prompt = _get_fallback_quality_prompt() @@ -504,7 +504,7 @@ async def get_file_content( try: full_path = project_dir / file_path if full_path.exists(): - return full_path.read_text() + return full_path.read_text(encoding="utf-8") return "" except Exception as e: logger.error(f"[Orchestrator] Failed to read {file_path}: {e}") diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index e5aa719d..6d443fd4 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -69,6 +69,7 @@ "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.22", + "cross-env": "^10.1.0", "electron": "^39.2.7", "electron-builder": "^26.0.12", "electron-vite": "^5.0.0", @@ -1101,6 +1102,13 @@ "node": ">=14.14" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -6215,6 +6223,24 @@ "optional": true, "peer": true }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index e988c7f9..6424f361 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -21,7 +21,7 @@ "scripts": { "postinstall": "node scripts/postinstall.cjs", "dev": "electron-vite dev", - "dev:debug": "DEBUG=true electron-vite dev", + "dev:debug": "cross-env DEBUG=true electron-vite dev", "dev:mcp": "electron-vite dev -- --remote-debugging-port=9222", "build": "electron-vite build", "start": "electron .", @@ -107,6 +107,7 @@ "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.22", + "cross-env": "^10.1.0", "electron": "^39.2.7", "electron-builder": "^26.0.12", "electron-vite": "^5.0.0", diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts index dc82ccae..f53f4170 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts @@ -59,7 +59,9 @@ export function runPythonSubprocess( // This is safe because: (1) user must explicitly enable via npm run dev:debug, // (2) it only enables our internal debug logging, not third-party framework debugging, // (3) no sensitive values are logged - only LLM reasoning and response text. - const safeEnvVars = ['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'DEBUG']; + // Include platform-specific vars needed for shell commands and CLI tools + // Windows: SYSTEMROOT, COMSPEC, PATHEXT, WINDIR for shell; USERPROFILE, APPDATA, LOCALAPPDATA for gh CLI auth + const safeEnvVars = ['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'DEBUG', 'SYSTEMROOT', 'COMSPEC', 'PATHEXT', 'WINDIR', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'HOMEDRIVE', 'HOMEPATH']; const filteredEnv: Record = {}; for (const key of safeEnvVars) { if (process.env[key]) {