fix: Allow windows to run CC PR Reviewer (#406)

* fix: Allow windows to run CC PR Reviewer

Signed-off-by: Alex Madera <e.a_madera@hotmail.com>

* solve auto claude comments

* fix linting

---------

Signed-off-by: Alex Madera <e.a_madera@hotmail.com>
This commit is contained in:
Alex
2025-12-30 09:45:52 +01:00
committed by GitHub
parent e7e6b52128
commit 2f662469e9
10 changed files with 223 additions and 73 deletions
+2 -1
View File
@@ -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
#
+66 -17
View File
@@ -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"
+115 -44
View File
@@ -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}")
@@ -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."
@@ -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
@@ -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:
@@ -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}")
+26
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -59,7 +59,9 @@ export function runPythonSubprocess<T = unknown>(
// 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<string, string> = {};
for (const key of safeEnvVars) {
if (process.env[key]) {