/ tools / claude_ci.py
claude_ci.py
   1  #!/usr/bin/env python3
   2  """
   3  Claude CI Integration for Alpha/Delta Protocol
   4  CSPEC-2026-001
   5  
   6  AI-powered code review integrated into Forgejo CI pipeline.
   7  Provides automated PR review, security analysis, architecture validation,
   8  and documentation drift detection.
   9  """
  10  
  11  import argparse
  12  import json
  13  import os
  14  import sys
  15  import hashlib
  16  import time
  17  from dataclasses import dataclass, field
  18  from pathlib import Path
  19  from typing import Optional, List, Dict, Any
  20  from datetime import datetime, timedelta
  21  
  22  # Optional imports with graceful degradation
  23  try:
  24      import anthropic
  25      HAS_ANTHROPIC = True
  26  except ImportError:
  27      HAS_ANTHROPIC = False
  28  
  29  try:
  30      import requests
  31      HAS_REQUESTS = True
  32  except ImportError:
  33      HAS_REQUESTS = False
  34  
  35  try:
  36      import yaml
  37      HAS_YAML = True
  38  except ImportError:
  39      HAS_YAML = False
  40  
  41  
  42  # ============================================================================
  43  # Configuration
  44  # ============================================================================
  45  
  46  @dataclass
  47  class CIConfig:
  48      """Configuration for Claude CI integration."""
  49  
  50      # API Settings - supports both direct API and OAuth
  51      anthropic_api_key: str = ""
  52      claude_session_key: str = ""  # OAuth session token for claude.ai
  53      claude_org_id: str = ""  # Organization ID for claude.ai
  54      auth_mode: str = "auto"  # auto, api, or oauth
  55  
  56      forgejo_url: str = "https://source.ac-dc.network"
  57      forgejo_token: str = ""
  58  
  59      # Context Settings
  60      context_repo_path: str = ""
  61      max_context_tokens: int = 150000
  62  
  63      # Model Settings
  64      model_pr_review: str = "claude-sonnet-4-20250514"
  65      model_security: str = "claude-sonnet-4-20250514"
  66      model_architecture: str = "claude-opus-4-20250514"
  67      max_output_tokens: int = 8192
  68  
  69      # Cost Controls (only applies to API mode)
  70      max_cost_per_pr: float = 1.00
  71      max_cost_per_day: float = 20.00
  72  
  73      # Behavior
  74      post_comments: bool = True
  75      security_gate_enabled: bool = True
  76      arch_gate_enabled: bool = True
  77  
  78      # Cache Settings
  79      cache_dir: str = "/tmp/claude-ci-cache"
  80      cache_ttl_seconds: int = 3600
  81  
  82      @classmethod
  83      def from_env(cls) -> "CIConfig":
  84          """Load configuration from environment variables."""
  85          return cls(
  86              anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""),
  87              claude_session_key=os.getenv("CLAUDE_SESSION_KEY", ""),
  88              claude_org_id=os.getenv("CLAUDE_ORG_ID", ""),
  89              auth_mode=os.getenv("CLAUDE_AUTH_MODE", "auto"),
  90              forgejo_url=os.getenv("FORGEJO_URL", "https://source.ac-dc.network"),
  91              forgejo_token=os.getenv("FORGEJO_TOKEN", ""),
  92              context_repo_path=os.getenv("CONTEXT_REPO_PATH", ""),
  93              max_context_tokens=int(os.getenv("MAX_CONTEXT_TOKENS", "150000")),
  94              model_pr_review=os.getenv("MODEL_PR_REVIEW", "claude-sonnet-4-20250514"),
  95              model_security=os.getenv("MODEL_SECURITY", "claude-sonnet-4-20250514"),
  96              model_architecture=os.getenv("MODEL_ARCHITECTURE", "claude-opus-4-20250514"),
  97              max_output_tokens=int(os.getenv("MAX_OUTPUT_TOKENS", "8192")),
  98              max_cost_per_pr=float(os.getenv("MAX_COST_PER_PR", "1.00")),
  99              max_cost_per_day=float(os.getenv("MAX_COST_PER_DAY", "20.00")),
 100              post_comments=os.getenv("POST_COMMENTS", "true").lower() == "true",
 101              security_gate_enabled=os.getenv("SECURITY_GATE", "true").lower() == "true",
 102              arch_gate_enabled=os.getenv("ARCH_GATE", "true").lower() == "true",
 103              cache_dir=os.getenv("CACHE_DIR", "/tmp/claude-ci-cache"),
 104              cache_ttl_seconds=int(os.getenv("CACHE_TTL", "3600")),
 105          )
 106  
 107      def get_auth_mode(self) -> str:
 108          """Determine which authentication mode to use."""
 109          if self.auth_mode != "auto":
 110              return self.auth_mode
 111          # Auto-detect: prefer OAuth if session key is set
 112          if self.claude_session_key:
 113              return "oauth"
 114          if self.anthropic_api_key:
 115              return "api"
 116          return "none"
 117  
 118      @classmethod
 119      def from_yaml(cls, path: str) -> "CIConfig":
 120          """Load configuration from YAML file."""
 121          if not HAS_YAML:
 122              raise ImportError("PyYAML required: pip install pyyaml")
 123  
 124          with open(path) as f:
 125              data = yaml.safe_load(f)
 126  
 127          return cls(**{k: v for k, v in data.items() if hasattr(cls, k)})
 128  
 129      def validate(self) -> List[str]:
 130          """Validate configuration, return list of errors."""
 131          errors = []
 132  
 133          auth_mode = self.get_auth_mode()
 134          if auth_mode == "none":
 135              errors.append("No authentication configured. Set ANTHROPIC_API_KEY or CLAUDE_SESSION_KEY")
 136          elif auth_mode == "api" and not self.anthropic_api_key:
 137              errors.append("ANTHROPIC_API_KEY not set (required for API mode)")
 138          elif auth_mode == "oauth" and not self.claude_session_key:
 139              errors.append("CLAUDE_SESSION_KEY not set (required for OAuth mode)")
 140  
 141          if not self.context_repo_path:
 142              errors.append("CONTEXT_REPO_PATH not set")
 143          elif not Path(self.context_repo_path).exists():
 144              errors.append(f"Context repo path does not exist: {self.context_repo_path}")
 145  
 146          return errors
 147  
 148  
 149  # ============================================================================
 150  # Context Loader
 151  # ============================================================================
 152  
 153  class ContextLoader:
 154      """
 155      Loads and manages project context from alpha-delta-context repository.
 156      Provides token-efficient context for Claude API calls.
 157      """
 158  
 159      # File patterns to load for context
 160      CONTEXT_PATTERNS = {
 161          "architecture": [
 162              "project/architecture/machine/*.cspec",
 163          ],
 164          "governance": [
 165              "project/governance/*.md",
 166          ],
 167          "security": [
 168              "infra/machine/security.cspec",
 169              "infra/machine/incident-response.cspec",
 170          ],
 171          "api": [
 172              "project/architecture/machine/api-specification.cspec",
 173          ],
 174          "testing": [
 175              "project/architecture/machine/testing-strategy.cspec",
 176          ],
 177          "decisions": [
 178              "project/decisions.md",
 179          ],
 180      }
 181  
 182      def __init__(self, config: CIConfig):
 183          self.config = config
 184          self.repo_path = Path(config.context_repo_path)
 185          self._context_cache: Optional[str] = None
 186          self._cache_time: Optional[datetime] = None
 187  
 188      def load_context_files(self, categories: Optional[List[str]] = None) -> Dict[str, str]:
 189          """
 190          Load context files from repository.
 191  
 192          Args:
 193              categories: Optional list of categories to load.
 194                         If None, loads all categories.
 195  
 196          Returns:
 197              Dict mapping file paths to contents.
 198          """
 199          if categories is None:
 200              categories = list(self.CONTEXT_PATTERNS.keys())
 201  
 202          files = {}
 203  
 204          for category in categories:
 205              patterns = self.CONTEXT_PATTERNS.get(category, [])
 206              for pattern in patterns:
 207                  for file_path in self.repo_path.glob(pattern):
 208                      if file_path.is_file():
 209                          try:
 210                              content = file_path.read_text(encoding="utf-8")
 211                              rel_path = str(file_path.relative_to(self.repo_path))
 212                              files[rel_path] = content
 213                          except Exception as e:
 214                              print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr)
 215  
 216          return files
 217  
 218      def build_context_prompt(self, categories: Optional[List[str]] = None) -> str:
 219          """
 220          Build a context prompt for Claude API calls.
 221          Uses caching to avoid repeated file reads.
 222  
 223          Returns:
 224              Formatted context string for system prompt.
 225          """
 226          # Check cache
 227          if (self._context_cache is not None and
 228              self._cache_time is not None and
 229              datetime.now() - self._cache_time < timedelta(seconds=self.config.cache_ttl_seconds)):
 230              return self._context_cache
 231  
 232          files = self.load_context_files(categories)
 233  
 234          sections = []
 235          sections.append("# Alpha/Delta Protocol Context")
 236          sections.append("")
 237          sections.append("You have access to the following project documentation:")
 238          sections.append("")
 239  
 240          for file_path, content in sorted(files.items()):
 241              sections.append(f"## File: {file_path}")
 242              sections.append("```")
 243              sections.append(content)
 244              sections.append("```")
 245              sections.append("")
 246  
 247          context = "\n".join(sections)
 248  
 249          # Cache the result
 250          self._context_cache = context
 251          self._cache_time = datetime.now()
 252  
 253          return context
 254  
 255      def estimate_tokens(self, text: str) -> int:
 256          """Rough token estimation (4 chars per token)."""
 257          return len(text) // 4
 258  
 259      def get_context_info(self) -> Dict[str, Any]:
 260          """Get information about loaded context."""
 261          files = self.load_context_files()
 262          total_chars = sum(len(content) for content in files.values())
 263  
 264          return {
 265              "repo_path": str(self.repo_path),
 266              "file_count": len(files),
 267              "total_chars": total_chars,
 268              "estimated_tokens": self.estimate_tokens(
 269                  self.build_context_prompt()
 270              ),
 271              "files": list(files.keys()),
 272          }
 273  
 274  
 275  # ============================================================================
 276  # Forgejo API Client
 277  # ============================================================================
 278  
 279  class ForgejoClient:
 280      """Client for Forgejo/Gitea API interactions."""
 281  
 282      def __init__(self, config: CIConfig):
 283          self.config = config
 284          self.base_url = config.forgejo_url.rstrip("/")
 285          self.headers = {}
 286          if config.forgejo_token:
 287              self.headers["Authorization"] = f"token {config.forgejo_token}"
 288  
 289      def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
 290          """Make API request."""
 291          if not HAS_REQUESTS:
 292              raise ImportError("requests required: pip install requests")
 293  
 294          url = f"{self.base_url}/api/v1{endpoint}"
 295          response = requests.request(method, url, headers=self.headers, **kwargs)
 296          response.raise_for_status()
 297  
 298          if response.content:
 299              return response.json()
 300          return {}
 301  
 302      def get_pr(self, owner: str, repo: str, pr_number: int) -> Dict[str, Any]:
 303          """Get pull request details."""
 304          return self._request("GET", f"/repos/{owner}/{repo}/pulls/{pr_number}")
 305  
 306      def get_pr_diff(self, owner: str, repo: str, pr_number: int) -> str:
 307          """Get pull request diff."""
 308          if not HAS_REQUESTS:
 309              raise ImportError("requests required: pip install requests")
 310  
 311          url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}.diff"
 312          response = requests.get(url, headers=self.headers)
 313          response.raise_for_status()
 314          return response.text
 315  
 316      def get_pr_files(self, owner: str, repo: str, pr_number: int) -> List[Dict[str, Any]]:
 317          """Get list of files changed in PR."""
 318          return self._request("GET", f"/repos/{owner}/{repo}/pulls/{pr_number}/files")
 319  
 320      def post_review_comment(
 321          self,
 322          owner: str,
 323          repo: str,
 324          pr_number: int,
 325          body: str,
 326          event: str = "COMMENT"
 327      ) -> Dict[str, Any]:
 328          """
 329          Post a review comment on a PR.
 330  
 331          Args:
 332              event: APPROVE, REQUEST_CHANGES, or COMMENT
 333          """
 334          return self._request(
 335              "POST",
 336              f"/repos/{owner}/{repo}/pulls/{pr_number}/reviews",
 337              json={"body": body, "event": event}
 338          )
 339  
 340      def set_commit_status(
 341          self,
 342          owner: str,
 343          repo: str,
 344          sha: str,
 345          state: str,
 346          context: str,
 347          description: str,
 348          target_url: Optional[str] = None
 349      ) -> Dict[str, Any]:
 350          """
 351          Set commit status.
 352  
 353          Args:
 354              state: pending, success, error, failure
 355          """
 356          data = {
 357              "state": state,
 358              "context": context,
 359              "description": description[:140],  # Forgejo limit
 360          }
 361          if target_url:
 362              data["target_url"] = target_url
 363  
 364          return self._request(
 365              "POST",
 366              f"/repos/{owner}/{repo}/statuses/{sha}",
 367              json=data
 368          )
 369  
 370  
 371  # ============================================================================
 372  # Claude OAuth Client (claude.ai web API)
 373  # ============================================================================
 374  
 375  class ClaudeCLIClient:
 376      """
 377      Client that uses the Claude Code CLI for inference.
 378      Leverages existing OAuth authentication from Claude Code.
 379      """
 380  
 381      def __init__(self, config: CIConfig):
 382          self.config = config
 383          self._check_cli()
 384  
 385      def _check_cli(self):
 386          """Verify Claude CLI is available."""
 387          import shutil
 388          if not shutil.which("claude"):
 389              raise RuntimeError("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code")
 390  
 391      def chat(self, system_prompt: str, user_message: str) -> str:
 392          """
 393          Send a chat message using Claude Code CLI.
 394          Uses -p flag for non-interactive output.
 395          """
 396          import subprocess
 397  
 398          # Combine prompts
 399          full_prompt = f"{user_message}"
 400  
 401          # Run claude CLI with print mode
 402          cmd = [
 403              "claude",
 404              "-p",  # Print mode (non-interactive)
 405              "--output-format", "text",
 406              "--system-prompt", system_prompt,
 407              # Note: --dangerously-skip-permissions not used (doesn't work with root)
 408              # CI runner should set up appropriate permissions
 409              full_prompt
 410          ]
 411  
 412          try:
 413              result = subprocess.run(
 414                  cmd,
 415                  capture_output=True,
 416                  text=True,
 417                  timeout=300,  # 5 minute timeout
 418              )
 419  
 420              if result.returncode != 0:
 421                  print(f"Claude CLI error: {result.stderr}", file=sys.stderr)
 422                  return ""
 423  
 424              return result.stdout.strip()
 425  
 426          except subprocess.TimeoutExpired:
 427              print("Claude CLI timed out", file=sys.stderr)
 428              return ""
 429          except Exception as e:
 430              print(f"Claude CLI error: {e}", file=sys.stderr)
 431              return ""
 432  
 433  
 434  class ClaudeOAuthClient:
 435      """
 436      DEPRECATED: Use ClaudeCLIClient instead.
 437      The claude.ai web API doesn't accept OAuth tokens from Claude Code.
 438      This class is kept for potential future use with session cookies.
 439      """
 440  
 441      BASE_URL = "https://claude.ai/api"
 442  
 443      def __init__(self, config: CIConfig):
 444          self.config = config
 445          self.session_key = config.claude_session_key
 446          self.org_id = config.claude_org_id
 447  
 448          if not HAS_REQUESTS:
 449              raise ImportError("requests required: pip install requests")
 450  
 451          # Try CLI first - raise if not available (caller should fall back to API)
 452          import shutil
 453          if shutil.which("claude"):
 454              print("Using Claude CLI for OAuth mode", file=sys.stderr)
 455              self._cli_client = ClaudeCLIClient(config)
 456          else:
 457              # CLI not found - raise so caller can fall back to API key if available
 458              raise RuntimeError("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code")
 459  
 460      def _get_headers(self) -> Dict[str, str]:
 461          """Get headers for claude.ai API requests."""
 462          headers = {
 463              "Content-Type": "application/json",
 464              "Accept": "application/json",
 465              "User-Agent": "Claude-CI/1.0",
 466          }
 467          # Support both OAuth bearer token and session cookie
 468          if self.session_key.startswith("sk-") or len(self.session_key) > 50:
 469              headers["Authorization"] = f"Bearer {self.session_key}"
 470          else:
 471              headers["Cookie"] = f"sessionKey={self.session_key}"
 472          return headers
 473  
 474      def chat(self, system_prompt: str, user_message: str) -> str:
 475          """
 476          Send a chat message.
 477          Uses CLI if available, otherwise attempts web API (may fail).
 478          """
 479          if self._cli_client:
 480              return self._cli_client.chat(system_prompt, user_message)
 481  
 482          # Fall back to web API (likely to fail with OAuth token)
 483          print("Warning: Claude CLI not found, attempting web API", file=sys.stderr)
 484          try:
 485              # Get org ID
 486              response = requests.get(
 487                  f"{self.BASE_URL}/organizations",
 488                  headers=self._get_headers(),
 489              )
 490              response.raise_for_status()
 491              orgs = response.json()
 492              if not orgs:
 493                  raise ValueError("No organization found")
 494              org_id = orgs[0].get("uuid", "")
 495  
 496              # Create conversation
 497              response = requests.post(
 498                  f"{self.BASE_URL}/organizations/{org_id}/chat_conversations",
 499                  headers=self._get_headers(),
 500                  json={"uuid": "", "name": "CI Review"},
 501              )
 502              response.raise_for_status()
 503              conv_id = response.json().get("uuid", "")
 504  
 505              # Send message
 506              full_message = f"{system_prompt}\n\n---\n\n{user_message}"
 507              response = requests.post(
 508                  f"{self.BASE_URL}/organizations/{org_id}/chat_conversations/{conv_id}/completion",
 509                  headers=self._get_headers(),
 510                  json={"prompt": full_message, "timezone": "UTC", "attachments": []},
 511                  stream=True,
 512              )
 513              response.raise_for_status()
 514  
 515              # Parse streaming response
 516              full_response = ""
 517              for line in response.iter_lines():
 518                  if line:
 519                      line_str = line.decode("utf-8")
 520                      if line_str.startswith("data: "):
 521                          try:
 522                              data = json.loads(line_str[6:])
 523                              if "completion" in data:
 524                                  full_response += data["completion"]
 525                          except json.JSONDecodeError:
 526                              continue
 527              return full_response
 528  
 529          except Exception as e:
 530              print(f"Web API error: {e}", file=sys.stderr)
 531              return ""
 532  
 533  
 534  # ============================================================================
 535  # Claude CI Client
 536  # ============================================================================
 537  
 538  @dataclass
 539  class ReviewResult:
 540      """Result of a Claude review."""
 541      recommendation: str  # APPROVE, REQUEST_CHANGES, COMMENT
 542      summary: str
 543      findings: List[Dict[str, Any]]
 544      raw_response: str
 545      model: str
 546      input_tokens: int
 547      output_tokens: int
 548      cost_estimate: float
 549  
 550  
 551  class ClaudeCIClient:
 552      """
 553      Claude API client for CI code review tasks.
 554      Provides PR review, security analysis, architecture validation,
 555      and documentation sync checking.
 556  
 557      Supports both direct API (ANTHROPIC_API_KEY) and OAuth (CLAUDE_SESSION_KEY).
 558      """
 559  
 560      # Pricing per 1K tokens (as of 2025) - only applies to API mode
 561      PRICING = {
 562          "claude-sonnet-4-20250514": {"input": 0.003, "output": 0.015},
 563          "claude-opus-4-20250514": {"input": 0.015, "output": 0.075},
 564      }
 565  
 566      def __init__(self, config: CIConfig, context_loader: ContextLoader):
 567          self.config = config
 568          self.context_loader = context_loader
 569          self.auth_mode = config.get_auth_mode()
 570  
 571          if self.auth_mode == "api":
 572              if not HAS_ANTHROPIC:
 573                  raise ImportError("anthropic required: pip install anthropic")
 574              self.client = anthropic.Anthropic(api_key=config.anthropic_api_key)
 575              self.oauth_client = None
 576          elif self.auth_mode == "oauth":
 577              # Try OAuth, but fall back to API if Claude CLI not available
 578              try:
 579                  self.client = None
 580                  self.oauth_client = ClaudeOAuthClient(config)
 581              except RuntimeError as e:
 582                  # Claude CLI not found - fall back to API if available
 583                  if config.anthropic_api_key and HAS_ANTHROPIC:
 584                      print(f"OAuth failed ({e}), falling back to API key", file=sys.stderr)
 585                      self.auth_mode = "api"
 586                      self.client = anthropic.Anthropic(api_key=config.anthropic_api_key)
 587                      self.oauth_client = None
 588                  else:
 589                      raise
 590          else:
 591              raise ValueError("No authentication configured")
 592  
 593      def _calculate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
 594          """Calculate cost estimate for API call."""
 595          pricing = self.PRICING.get(model, {"input": 0.003, "output": 0.015})
 596          return (input_tokens / 1000 * pricing["input"] +
 597                  output_tokens / 1000 * pricing["output"])
 598  
 599      def _build_system_prompt(self, task_type: str) -> str:
 600          """Build system prompt with context and task instructions."""
 601          context = self.context_loader.build_context_prompt()
 602  
 603          base_prompt = """You are an expert code reviewer for the Alpha/Delta Protocol,
 604  a dual-chain blockchain system with privacy-preserving features.
 605  
 606  Your expertise includes:
 607  - Rust programming and blockchain development
 608  - Zero-knowledge proofs and privacy-preserving computation
 609  - DeFi protocols, DEX design, and perpetual futures
 610  - Security best practices and vulnerability detection
 611  - Cross-chain bridge security
 612  
 613  """
 614  
 615          task_prompts = {
 616              "pr_review": """
 617  TASK: Review the provided pull request diff.
 618  
 619  Analyze for:
 620  1. Code quality and Rust best practices
 621  2. Potential bugs or logic errors
 622  3. Security concerns
 623  4. Alignment with project architecture
 624  5. Missing tests or documentation
 625  
 626  Respond with JSON:
 627  {
 628      "recommendation": "APPROVE" | "REQUEST_CHANGES" | "COMMENT",
 629      "summary": "Brief summary of review",
 630      "findings": [
 631          {
 632              "severity": "critical" | "high" | "medium" | "low" | "info",
 633              "category": "security" | "bug" | "style" | "architecture" | "docs",
 634              "file": "path/to/file.rs",
 635              "line": 42,
 636              "title": "Finding title",
 637              "description": "Detailed description",
 638              "suggestion": "How to fix"
 639          }
 640      ]
 641  }
 642  """,
 643              "security_review": """
 644  TASK: Perform a security-focused review of the provided code changes.
 645  
 646  Focus on:
 647  1. Authentication and authorization issues
 648  2. Input validation and sanitization
 649  3. Cryptographic implementation correctness
 650  4. Privacy leaks in zero-knowledge circuits
 651  5. Cross-chain bridge vulnerabilities
 652  6. Reentrancy and state manipulation
 653  7. Integer overflow/underflow
 654  8. Denial of service vectors
 655  
 656  For each finding, include CWE ID if applicable.
 657  
 658  Respond with JSON:
 659  {
 660      "recommendation": "APPROVE" | "REQUEST_CHANGES" | "COMMENT",
 661      "summary": "Security assessment summary",
 662      "risk_level": "critical" | "high" | "medium" | "low" | "none",
 663      "findings": [
 664          {
 665              "severity": "critical" | "high" | "medium" | "low",
 666              "cwe_id": "CWE-XXX",
 667              "title": "Vulnerability title",
 668              "description": "Detailed description",
 669              "file": "path/to/file.rs",
 670              "line": 42,
 671              "remediation": "How to fix"
 672          }
 673      ]
 674  }
 675  """,
 676              "architecture_validation": """
 677  TASK: Validate the code changes against the Alpha/Delta Protocol specifications.
 678  
 679  Check for:
 680  1. Compliance with Technical Specification 3.0
 681  2. Correct use of protocol primitives (AX/DX tokens, validators, etc.)
 682  3. Governance rule adherence
 683  4. Cross-chain bridge protocol compliance
 684  5. Privacy circuit specifications
 685  
 686  Reference specific sections of the loaded specifications in your findings.
 687  
 688  Respond with JSON:
 689  {
 690      "recommendation": "APPROVE" | "REQUEST_CHANGES" | "COMMENT",
 691      "summary": "Architecture compliance summary",
 692      "compliant": true | false,
 693      "findings": [
 694          {
 695              "severity": "critical" | "high" | "medium" | "low",
 696              "spec_section": "Reference to spec section",
 697              "title": "Violation title",
 698              "description": "How the code violates the spec",
 699              "suggestion": "How to make compliant"
 700          }
 701      ]
 702  }
 703  """,
 704              "docs_sync": """
 705  TASK: Check if the code changes require documentation updates.
 706  
 707  Analyze:
 708  1. New public APIs that need documentation
 709  2. Changed behavior that affects existing docs
 710  3. Removed features that should be removed from docs
 711  4. Configuration changes
 712  5. Migration requirements
 713  
 714  Respond with JSON:
 715  {
 716      "recommendation": "APPROVE" | "REQUEST_CHANGES" | "COMMENT",
 717      "summary": "Documentation sync summary",
 718      "updates_needed": true | false,
 719      "findings": [
 720          {
 721              "category": "new_api" | "changed_behavior" | "removed" | "config" | "migration",
 722              "title": "Update needed",
 723              "description": "What documentation needs updating",
 724              "suggested_doc_path": "Where to add/update docs",
 725              "suggested_content": "Draft documentation text"
 726          }
 727      ]
 728  }
 729  """
 730          }
 731  
 732          return base_prompt + context + "\n\n" + task_prompts.get(task_type, "")
 733  
 734      def _call_api(self, system_prompt: str, user_prompt: str, model: str) -> ReviewResult:
 735          """Make API call to Claude using configured authentication."""
 736          if self.auth_mode == "oauth":
 737              # Use OAuth client (claude.ai web API)
 738              raw_response = self.oauth_client.chat(system_prompt, user_prompt)
 739              input_tokens = 0  # Not available via OAuth
 740              output_tokens = 0
 741              cost = 0.0  # No direct cost for Max Plan
 742          else:
 743              # Use direct API
 744              response = self.client.messages.create(
 745                  model=model,
 746                  max_tokens=self.config.max_output_tokens,
 747                  system=system_prompt,
 748                  messages=[{"role": "user", "content": user_prompt}]
 749              )
 750              raw_response = response.content[0].text
 751              input_tokens = response.usage.input_tokens
 752              output_tokens = response.usage.output_tokens
 753              cost = self._calculate_cost(model, input_tokens, output_tokens)
 754  
 755          # Parse JSON response
 756          try:
 757              # Try to extract JSON from response
 758              json_start = raw_response.find("{")
 759              json_end = raw_response.rfind("}") + 1
 760              if json_start >= 0 and json_end > json_start:
 761                  parsed = json.loads(raw_response[json_start:json_end])
 762              else:
 763                  parsed = {"recommendation": "COMMENT", "summary": raw_response, "findings": []}
 764          except json.JSONDecodeError:
 765              parsed = {"recommendation": "COMMENT", "summary": raw_response, "findings": []}
 766  
 767          return ReviewResult(
 768              recommendation=parsed.get("recommendation", "COMMENT"),
 769              summary=parsed.get("summary", ""),
 770              findings=parsed.get("findings", []),
 771              raw_response=raw_response,
 772              model=model,
 773              input_tokens=input_tokens,
 774              output_tokens=output_tokens,
 775              cost_estimate=cost,
 776          )
 777  
 778      def review_pull_request(self, diff: str, pr_info: Optional[Dict] = None) -> ReviewResult:
 779          """
 780          Review a pull request.
 781  
 782          Args:
 783              diff: The PR diff content
 784              pr_info: Optional PR metadata (title, description, etc.)
 785          """
 786          system_prompt = self._build_system_prompt("pr_review")
 787  
 788          user_prompt = "Please review the following pull request:\n\n"
 789          if pr_info:
 790              user_prompt += f"**Title:** {pr_info.get('title', 'N/A')}\n"
 791              user_prompt += f"**Description:** {pr_info.get('body', 'N/A')}\n\n"
 792          user_prompt += f"**Diff:**\n```diff\n{diff}\n```"
 793  
 794          return self._call_api(system_prompt, user_prompt, self.config.model_pr_review)
 795  
 796      def security_review(self, diff: str) -> ReviewResult:
 797          """Perform security-focused review."""
 798          system_prompt = self._build_system_prompt("security_review")
 799          user_prompt = f"Please perform a security review of the following changes:\n\n```diff\n{diff}\n```"
 800  
 801          return self._call_api(system_prompt, user_prompt, self.config.model_security)
 802  
 803      def validate_architecture(self, diff: str, commit_message: Optional[str] = None) -> ReviewResult:
 804          """Validate changes against architecture specifications."""
 805          system_prompt = self._build_system_prompt("architecture_validation")
 806  
 807          user_prompt = "Please validate the following changes against the protocol specifications:\n\n"
 808          if commit_message:
 809              user_prompt += f"**Commit Message:** {commit_message}\n\n"
 810          user_prompt += f"**Changes:**\n```diff\n{diff}\n```"
 811  
 812          return self._call_api(system_prompt, user_prompt, self.config.model_architecture)
 813  
 814      def check_documentation_sync(self, diff: str) -> ReviewResult:
 815          """Check if documentation needs updating."""
 816          system_prompt = self._build_system_prompt("docs_sync")
 817          user_prompt = f"Please check if the following changes require documentation updates:\n\n```diff\n{diff}\n```"
 818  
 819          return self._call_api(system_prompt, user_prompt, self.config.model_pr_review)
 820  
 821  
 822  # ============================================================================
 823  # CLI
 824  # ============================================================================
 825  
 826  def cmd_context_info(args, config: CIConfig):
 827      """Show context repository information."""
 828      loader = ContextLoader(config)
 829      info = loader.get_context_info()
 830  
 831      print("šŸ“‹ Context Repository Information")
 832      print(f"   Path: {info['repo_path']}")
 833      print(f"   Files loaded: {info['file_count']}")
 834      print(f"   Total characters: {info['total_chars']:,}")
 835      print(f"   Estimated tokens: {info['estimated_tokens']:,}")
 836      print("\n   Files:")
 837      for f in info['files']:
 838          print(f"   - {f}")
 839  
 840  
 841  def cmd_review(args, config: CIConfig):
 842      """Review a pull request."""
 843      loader = ContextLoader(config)
 844      client = ClaudeCIClient(config, loader)
 845      forgejo = ForgejoClient(config)
 846  
 847      # Get PR info and diff
 848      owner, repo = args.repo.split("/")
 849      pr_info = forgejo.get_pr(owner, repo, args.pr)
 850      diff = forgejo.get_pr_diff(owner, repo, args.pr)
 851  
 852      print(f"šŸ” Reviewing PR #{args.pr}: {pr_info.get('title', 'N/A')}")
 853  
 854      result = client.review_pull_request(diff, pr_info)
 855  
 856      print(f"\nšŸ“Š Review Result: {result.recommendation}")
 857      print(f"   Summary: {result.summary}")
 858      print(f"   Findings: {len(result.findings)}")
 859      print(f"   Cost: ${result.cost_estimate:.4f}")
 860  
 861      if result.findings:
 862          print("\n   Findings:")
 863          for f in result.findings:
 864              print(f"   - [{f.get('severity', 'info')}] {f.get('title', 'No title')}")
 865  
 866      # Post comment if enabled
 867      if config.post_comments and config.forgejo_token:
 868          comment = format_review_comment(result)
 869          forgejo.post_review_comment(owner, repo, args.pr, comment, result.recommendation)
 870          print("\nāœ… Posted review comment to PR")
 871  
 872      # Output JSON if requested
 873      if args.json:
 874          print("\n" + json.dumps({
 875              "recommendation": result.recommendation,
 876              "summary": result.summary,
 877              "findings": result.findings,
 878              "cost": result.cost_estimate,
 879          }, indent=2))
 880  
 881  
 882  def cmd_pr_review(args, config: CIConfig):
 883      """Review PR using local diff file."""
 884      # Debug output (to stderr to not corrupt JSON output)
 885      print(f"Debug: cwd = {os.getcwd()}", file=sys.stderr)
 886      print(f"Debug: diff arg = {args.diff}", file=sys.stderr)
 887  
 888      loader = ContextLoader(config)
 889      client = ClaudeCIClient(config, loader)
 890  
 891      # Read diff from file or stdin
 892      if args.diff == "-":
 893          diff = sys.stdin.read()
 894      else:
 895          diff_path = Path(args.diff)
 896          print(f"Debug: diff_path = {diff_path}", file=sys.stderr)
 897          print(f"Debug: diff_path.exists() = {diff_path.exists()}", file=sys.stderr)
 898          print(f"Debug: diff_path.absolute() = {diff_path.absolute()}", file=sys.stderr)
 899          if not diff_path.exists():
 900              print(f"āŒ Error: Diff file not found: {diff_path.absolute()}", file=sys.stderr)
 901              sys.exit(1)
 902          diff = diff_path.read_text()
 903          print(f"Debug: diff length = {len(diff)} chars", file=sys.stderr)
 904  
 905      pr_info = {"title": args.title or "N/A", "body": ""}
 906  
 907      # Human-readable output goes to stderr when JSON mode is enabled
 908      output = sys.stderr if args.json else sys.stdout
 909  
 910      print(f"šŸ” Reviewing PR: {args.title or 'Unknown'}", file=output)
 911      print(f"   Diff size: {len(diff)} bytes", file=output)
 912  
 913      result = client.review_pull_request(diff, pr_info)
 914  
 915      print(f"\nšŸ“Š Review Result: {result.recommendation}", file=output)
 916      print(f"   Summary: {result.summary}", file=output)
 917      print(f"   Findings: {len(result.findings)}", file=output)
 918      print(f"   Cost: ${result.cost_estimate:.4f}", file=output)
 919  
 920      if result.findings:
 921          print("\n   Findings:", file=output)
 922          for f in result.findings:
 923              print(f"   - [{f.get('severity', 'info')}] {f.get('title', 'No title')}", file=output)
 924  
 925      # Output JSON to stdout when requested
 926      if args.json:
 927          print(json.dumps({
 928              "recommendation": result.recommendation,
 929              "summary": result.summary,
 930              "findings": result.findings,
 931              "cost": result.cost_estimate,
 932          }, indent=2))
 933  
 934  
 935  def cmd_security_review(args, config: CIConfig):
 936      """Perform security review."""
 937      # Debug output (to stderr to not corrupt JSON output)
 938      print(f"Debug: cwd = {os.getcwd()}", file=sys.stderr)
 939      print(f"Debug: diff arg = {args.diff}", file=sys.stderr)
 940  
 941      loader = ContextLoader(config)
 942      client = ClaudeCIClient(config, loader)
 943  
 944      # Read diff from file or stdin
 945      if args.diff == "-":
 946          diff = sys.stdin.read()
 947      else:
 948          diff_path = Path(args.diff)
 949          print(f"Debug: diff_path = {diff_path}", file=sys.stderr)
 950          print(f"Debug: diff_path.exists() = {diff_path.exists()}", file=sys.stderr)
 951          print(f"Debug: diff_path.absolute() = {diff_path.absolute()}", file=sys.stderr)
 952          if not diff_path.exists():
 953              print(f"āŒ Error: Diff file not found: {diff_path.absolute()}", file=sys.stderr)
 954              sys.exit(1)
 955          diff = diff_path.read_text()
 956          print(f"Debug: diff length = {len(diff)} chars", file=sys.stderr)
 957  
 958      # Human-readable output goes to stderr when JSON mode is enabled
 959      output = sys.stderr if args.json else sys.stdout
 960  
 961      print("šŸ”’ Performing security review...", file=output)
 962  
 963      result = client.security_review(diff)
 964  
 965      print(f"\nšŸ“Š Security Review Result: {result.recommendation}", file=output)
 966      print(f"   Summary: {result.summary}", file=output)
 967      print(f"   Findings: {len(result.findings)}", file=output)
 968      print(f"   Cost: ${result.cost_estimate:.4f}", file=output)
 969  
 970      if result.findings:
 971          print("\n   Security Findings:", file=output)
 972          for f in result.findings:
 973              cwe = f.get("cwe_id", "")
 974              cwe_str = f" ({cwe})" if cwe else ""
 975              print(f"   - [{f.get('severity', 'info')}]{cwe_str} {f.get('title', 'No title')}", file=output)
 976  
 977      # Output JSON to stdout when requested
 978      if args.json:
 979          print(json.dumps({
 980              "recommendation": result.recommendation,
 981              "summary": result.summary,
 982              "findings": result.findings,
 983              "cost": result.cost_estimate,
 984          }, indent=2))
 985  
 986      # Exit with error if critical findings
 987      if config.security_gate_enabled:
 988          critical = [f for f in result.findings if f.get("severity") == "critical"]
 989          if critical:
 990              print(f"\nāŒ Security gate failed: {len(critical)} critical finding(s)", file=sys.stderr)
 991              sys.exit(1)
 992  
 993  
 994  def cmd_validate_arch(args, config: CIConfig):
 995      """Validate architecture compliance."""
 996      loader = ContextLoader(config)
 997      client = ClaudeCIClient(config, loader)
 998  
 999      # Get diff from git
1000      import subprocess
1001      if args.commit:
1002          diff = subprocess.check_output(
1003              ["git", "show", args.commit, "--format="],
1004              cwd=args.repo_path or "."
1005          ).decode()
1006          commit_msg = subprocess.check_output(
1007              ["git", "log", "-1", "--format=%s", args.commit],
1008              cwd=args.repo_path or "."
1009          ).decode().strip()
1010      else:
1011          diff = subprocess.check_output(
1012              ["git", "diff", "HEAD~1"],
1013              cwd=args.repo_path or "."
1014          ).decode()
1015          commit_msg = None
1016  
1017      # Human-readable output goes to stderr when JSON mode is enabled
1018      output = sys.stderr if args.json else sys.stdout
1019  
1020      print("šŸ“ Validating architecture compliance...", file=output)
1021  
1022      result = client.validate_architecture(diff, commit_msg)
1023  
1024      print(f"\nšŸ“Š Architecture Validation: {result.recommendation}", file=output)
1025      print(f"   Summary: {result.summary}", file=output)
1026      print(f"   Findings: {len(result.findings)}", file=output)
1027      print(f"   Cost: ${result.cost_estimate:.4f}", file=output)
1028  
1029      if result.findings:
1030          print("\n   Architecture Findings:", file=output)
1031          for f in result.findings:
1032              print(f"   - [{f.get('severity', 'info')}] {f.get('title', 'No title')}", file=output)
1033              if f.get("spec_section"):
1034                  print(f"     Spec: {f.get('spec_section')}", file=output)
1035  
1036      # Output JSON to stdout when requested
1037      if args.json:
1038          print(json.dumps({
1039              "recommendation": result.recommendation,
1040              "summary": result.summary,
1041              "findings": result.findings,
1042              "cost": result.cost_estimate,
1043          }, indent=2))
1044  
1045  
1046  def cmd_docs_sync(args, config: CIConfig):
1047      """Check documentation sync."""
1048      # Debug output (to stderr)
1049      print(f"Debug: cwd = {os.getcwd()}", file=sys.stderr)
1050      print(f"Debug: diff arg = {args.diff}", file=sys.stderr)
1051  
1052      loader = ContextLoader(config)
1053      client = ClaudeCIClient(config, loader)
1054  
1055      # Read diff
1056      if args.diff == "-":
1057          diff = sys.stdin.read()
1058      else:
1059          diff_path = Path(args.diff)
1060          print(f"Debug: diff_path.exists() = {diff_path.exists()}", file=sys.stderr)
1061          if not diff_path.exists():
1062              print(f"āŒ Error: Diff file not found: {diff_path.absolute()}", file=sys.stderr)
1063              sys.exit(1)
1064          diff = diff_path.read_text()
1065          print(f"Debug: diff length = {len(diff)} chars", file=sys.stderr)
1066  
1067      # Human-readable output goes to stderr when JSON mode is enabled
1068      output = sys.stderr if args.json else sys.stdout
1069  
1070      print("šŸ“š Checking documentation sync...", file=output)
1071  
1072      result = client.check_documentation_sync(diff)
1073  
1074      print(f"\nšŸ“Š Documentation Sync: {result.recommendation}", file=output)
1075      print(f"   Summary: {result.summary}", file=output)
1076      print(f"   Updates needed: {len(result.findings)}", file=output)
1077      print(f"   Cost: ${result.cost_estimate:.4f}", file=output)
1078  
1079      if result.findings:
1080          print("\n   Documentation Updates Needed:", file=output)
1081          for f in result.findings:
1082              print(f"   - [{f.get('category', 'other')}] {f.get('title', 'No title')}", file=output)
1083  
1084      # Output JSON to stdout when requested
1085      if args.json:
1086          print(json.dumps({
1087              "recommendation": result.recommendation,
1088              "summary": result.summary,
1089              "findings": result.findings,
1090              "cost": result.cost_estimate,
1091          }, indent=2))
1092  
1093  
1094  def cmd_post_comment(args, config: CIConfig):
1095      """Post a combined review comment to PR."""
1096      if not HAS_REQUESTS:
1097          print("āŒ Error: requests required: pip install requests", file=sys.stderr)
1098          sys.exit(1)
1099  
1100      comment_parts = ["## šŸ¤– Claude CI Review Summary", ""]
1101  
1102      # PR Review
1103      if args.review and Path(args.review).exists():
1104          try:
1105              with open(args.review) as f:
1106                  data = json.load(f)
1107              rec = data.get('recommendation', 'N/A')
1108              summary = data.get('summary', 'No summary')[:300]
1109              comment_parts.append(f"### PR Review: {rec}")
1110              comment_parts.append(summary)
1111              comment_parts.append("")
1112          except Exception as e:
1113              print(f"PR review not available: {e}", file=sys.stderr)
1114  
1115      # Security Review
1116      if args.security and Path(args.security).exists():
1117          try:
1118              with open(args.security) as f:
1119                  data = json.load(f)
1120              rec = data.get('recommendation', 'N/A')
1121              summary = data.get('summary', 'No summary')[:300]
1122              comment_parts.append(f"### Security Review: {rec}")
1123              comment_parts.append(summary)
1124              comment_parts.append("")
1125          except Exception as e:
1126              print(f"Security review not available: {e}", file=sys.stderr)
1127  
1128      # Architecture
1129      if args.arch and Path(args.arch).exists():
1130          try:
1131              with open(args.arch) as f:
1132                  data = json.load(f)
1133              rec = data.get('recommendation', 'N/A')
1134              comment_parts.append(f"### Architecture: {rec}")
1135              comment_parts.append("")
1136          except Exception as e:
1137              print(f"Arch review not available: {e}", file=sys.stderr)
1138  
1139      # Docs
1140      if args.docs and Path(args.docs).exists():
1141          try:
1142              with open(args.docs) as f:
1143                  data = json.load(f)
1144              rec = data.get('recommendation', 'N/A')
1145              comment_parts.append(f"### Documentation: {rec}")
1146              comment_parts.append("")
1147          except Exception as e:
1148              print(f"Docs review not available: {e}", file=sys.stderr)
1149  
1150      comment_parts.append("---")
1151      comment_parts.append("*Powered by Claude CI (CSPEC-2026-001)*")
1152  
1153      comment_body = "\n".join(comment_parts)
1154      print(f"Comment body ({len(comment_body)} chars):", file=sys.stderr)
1155      print(comment_body, file=sys.stderr)
1156  
1157      # Post to Forgejo
1158      forgejo_url = config.forgejo_url.rstrip("/")
1159      forgejo_token = config.forgejo_token
1160  
1161      if not forgejo_token:
1162          print("āŒ Error: FORGEJO_TOKEN not set", file=sys.stderr)
1163          sys.exit(1)
1164  
1165      url = f"{forgejo_url}/api/v1/repos/{args.repo}/issues/{args.pr}/comments"
1166      headers = {
1167          "Authorization": f"token {forgejo_token}",
1168          "Content-Type": "application/json",
1169      }
1170      payload = {"body": comment_body}
1171  
1172      print(f"Posting to: {url}", file=sys.stderr)
1173      response = requests.post(url, headers=headers, json=payload)
1174  
1175      if response.status_code in (200, 201):
1176          print(f"āœ… Comment posted successfully", file=sys.stderr)
1177      else:
1178          print(f"āŒ Failed to post comment: {response.status_code}", file=sys.stderr)
1179          print(response.text, file=sys.stderr)
1180          sys.exit(1)
1181  
1182  
1183  def format_review_comment(result: ReviewResult) -> str:
1184      """Format review result as Markdown comment for PR."""
1185      emoji_map = {
1186          "APPROVE": "āœ…",
1187          "REQUEST_CHANGES": "āš ļø",
1188          "COMMENT": "šŸ’¬",
1189      }
1190      severity_emoji = {
1191          "critical": "šŸ”“",
1192          "high": "🟠",
1193          "medium": "🟔",
1194          "low": "šŸ”µ",
1195          "info": "ā„¹ļø",
1196      }
1197  
1198      lines = []
1199      lines.append(f"## {emoji_map.get(result.recommendation, 'šŸ’¬')} Claude CI Review")
1200      lines.append("")
1201      lines.append(f"**Recommendation:** {result.recommendation}")
1202      lines.append("")
1203      lines.append(f"**Summary:** {result.summary}")
1204      lines.append("")
1205  
1206      if result.findings:
1207          lines.append("### Findings")
1208          lines.append("")
1209          for f in result.findings:
1210              sev = f.get("severity", "info")
1211              emoji = severity_emoji.get(sev, "ā„¹ļø")
1212              lines.append(f"#### {emoji} {f.get('title', 'Finding')}")
1213              lines.append("")
1214              lines.append(f"**Severity:** {sev}")
1215              if f.get("file"):
1216                  lines.append(f"**File:** `{f.get('file')}`")
1217              if f.get("line"):
1218                  lines.append(f"**Line:** {f.get('line')}")
1219              if f.get("cwe_id"):
1220                  lines.append(f"**CWE:** {f.get('cwe_id')}")
1221              lines.append("")
1222              lines.append(f.get("description", ""))
1223              lines.append("")
1224              if f.get("suggestion") or f.get("remediation"):
1225                  lines.append(f"**Suggestion:** {f.get('suggestion') or f.get('remediation')}")
1226                  lines.append("")
1227  
1228      lines.append("---")
1229      lines.append(f"*šŸ¤– Automated review by Claude CI | Model: {result.model} | "
1230                   f"Cost: ${result.cost_estimate:.4f}*")
1231  
1232      return "\n".join(lines)
1233  
1234  
1235  def main():
1236      parser = argparse.ArgumentParser(
1237          description="Claude CI Integration for Alpha/Delta Protocol",
1238          formatter_class=argparse.RawDescriptionHelpFormatter,
1239      )
1240      parser.add_argument(
1241          "--config", "-c",
1242          help="Path to YAML config file",
1243      )
1244  
1245      subparsers = parser.add_subparsers(dest="command", help="Commands")
1246  
1247      # context-info
1248      ctx_parser = subparsers.add_parser("context-info", help="Show context information")
1249  
1250      # review (via API)
1251      review_parser = subparsers.add_parser("review", help="Review a pull request via API")
1252      review_parser.add_argument("--repo", "-r", required=True, help="Repository (owner/repo)")
1253      review_parser.add_argument("--pr", "-p", type=int, required=True, help="PR number")
1254      review_parser.add_argument("--json", action="store_true", help="Output JSON")
1255  
1256      # pr-review (via local diff file)
1257      pr_review_parser = subparsers.add_parser("pr-review", help="Review PR using local diff file")
1258      pr_review_parser.add_argument("--diff", "-d", required=True, help="Path to diff file or - for stdin")
1259      pr_review_parser.add_argument("--title", "-t", help="PR title for context")
1260      pr_review_parser.add_argument("--json", action="store_true", help="Output JSON")
1261  
1262      # security-review
1263      sec_parser = subparsers.add_parser("security-review", help="Security-focused review")
1264      sec_parser.add_argument("--diff", "-d", required=True, help="Path to diff file or - for stdin")
1265      sec_parser.add_argument("--json", action="store_true", help="Output JSON")
1266  
1267      # validate-arch
1268      arch_parser = subparsers.add_parser("validate-arch", help="Validate architecture")
1269      arch_parser.add_argument("--commit", help="Commit SHA to validate")
1270      arch_parser.add_argument("--repo-path", help="Path to repository")
1271      arch_parser.add_argument("--json", action="store_true", help="Output JSON")
1272  
1273      # docs-sync
1274      docs_parser = subparsers.add_parser("docs-sync", help="Check documentation sync")
1275      docs_parser.add_argument("--diff", "-d", required=True, help="Path to diff file or - for stdin")
1276      docs_parser.add_argument("--json", action="store_true", help="Output JSON")
1277  
1278      # post-comment
1279      comment_parser = subparsers.add_parser("post-comment", help="Post combined review comment to PR")
1280      comment_parser.add_argument("--pr", required=True, help="PR number")
1281      comment_parser.add_argument("--repo", required=True, help="Repository (owner/repo)")
1282      comment_parser.add_argument("--review", help="Path to review-result.json")
1283      comment_parser.add_argument("--security", help="Path to security-result.json")
1284      comment_parser.add_argument("--arch", help="Path to arch-result.json")
1285      comment_parser.add_argument("--docs", help="Path to docs-result.json")
1286  
1287      args = parser.parse_args()
1288  
1289      if not args.command:
1290          parser.print_help()
1291          sys.exit(1)
1292  
1293      # Load config
1294      if args.config:
1295          config = CIConfig.from_yaml(args.config)
1296      else:
1297          config = CIConfig.from_env()
1298  
1299      # Validate config (skip for commands that don't need full config)
1300      skip_validation_commands = {"context-info", "post-comment"}
1301      errors = config.validate()
1302      if errors and args.command not in skip_validation_commands:
1303          for error in errors:
1304              print(f"āŒ Config error: {error}", file=sys.stderr)
1305          sys.exit(1)
1306  
1307      # Dispatch command
1308      commands = {
1309          "context-info": cmd_context_info,
1310          "review": cmd_review,
1311          "pr-review": cmd_pr_review,
1312          "security-review": cmd_security_review,
1313          "validate-arch": cmd_validate_arch,
1314          "docs-sync": cmd_docs_sync,
1315          "post-comment": cmd_post_comment,
1316      }
1317  
1318      cmd_func = commands.get(args.command)
1319      if cmd_func:
1320          try:
1321              cmd_func(args, config)
1322          except Exception as e:
1323              print(f"āŒ Error: {e}", file=sys.stderr)
1324              if os.getenv("DEBUG"):
1325                  raise
1326              sys.exit(1)
1327      else:
1328          parser.print_help()
1329          sys.exit(1)
1330  
1331  
1332  if __name__ == "__main__":
1333      main()
1334  # Test