secondlook 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codereview/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,75 @@
1
+ """Configuration loading and validation."""
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from pydantic import BaseModel, Field
9
+ import yaml
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ProviderConfig(BaseModel):
15
+ model: str = ""
16
+ api_key: str = ""
17
+ endpoint: str | None = None
18
+
19
+
20
+ class DefaultsConfig(BaseModel):
21
+ mode: str = "raw"
22
+ focus: str = "all"
23
+ max_tokens: int = 2048
24
+ timeout_sec: float = 60.0
25
+ max_retries: int = 3
26
+ provider: str = "gemini"
27
+
28
+
29
+ class Config(BaseModel):
30
+ default_provider: str = "gemini"
31
+ providers: dict[str, ProviderConfig] = Field(default_factory=dict)
32
+ defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
33
+
34
+
35
+ def _expand_env(value: str) -> str:
36
+ if not isinstance(value, str):
37
+ return value
38
+
39
+ def _replacer(match: re.Match) -> str:
40
+ return os.environ.get(match.group(1), "")
41
+
42
+ return re.sub(r"\$\{(\w+)\}", _replacer, value)
43
+
44
+
45
+ def _find_config_path(path: str | None) -> str:
46
+ if path:
47
+ return path
48
+
49
+ env_path = os.environ.get("CODEREVIEW_CONFIG")
50
+ if env_path:
51
+ return env_path
52
+
53
+ xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
54
+ return str(Path(xdg) / "codereview" / "config.yaml")
55
+
56
+
57
+ def load_config(path: str | None = None) -> Config:
58
+ config_path = _find_config_path(path)
59
+
60
+ if os.path.exists(config_path):
61
+ logger.info("Loading config from %s", config_path)
62
+ with open(config_path, encoding="utf-8") as f:
63
+ raw = yaml.safe_load(f) or {}
64
+ else:
65
+ logger.info("No config file found at %s, using defaults", config_path)
66
+ raw = {}
67
+
68
+ if "providers" in raw:
69
+ for name, provider in raw["providers"].items():
70
+ if isinstance(provider, dict):
71
+ for key in ("api_key", "model", "endpoint"):
72
+ if key in provider and isinstance(provider[key], str):
73
+ provider[key] = _expand_env(provider[key])
74
+
75
+ return Config(**raw)
codereview/main.py ADDED
@@ -0,0 +1,42 @@
1
+ """secondlook — multi-provider code review via MCP."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+
7
+ from mcp.server import Server
8
+ from mcp.server.stdio import stdio_server
9
+
10
+ from codereview.tool.review import register_tools
11
+
12
+ logger = logging.getLogger("codereview")
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(
17
+ description="secondlook — multi-provider code review via MCP",
18
+ )
19
+ parser.add_argument(
20
+ "--config",
21
+ default=None,
22
+ help="Path to config file (default: ~/.config/codereview/config.yaml)",
23
+ )
24
+ args = parser.parse_args()
25
+
26
+ logging.basicConfig(
27
+ level=logging.WARNING,
28
+ format="%(levelname)s | %(name)s | %(message)s",
29
+ stream=sys.stderr,
30
+ )
31
+
32
+ server = Server("codereview")
33
+ register_tools(server, args.config)
34
+
35
+ logger.info("codereview-mcp starting")
36
+ import asyncio
37
+
38
+ asyncio.run(stdio_server(server))
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
File without changes
@@ -0,0 +1,38 @@
1
+ """Prompt templates for each review mode."""
2
+
3
+ RAW = """You are a code reviewer. Review the following code for bugs,
4
+ security issues, performance problems, and style violations.
5
+ Be concise. Use severity markers: [CRITICAL], [HIGH], [MEDIUM], [LOW].
6
+ Reference exact line numbers when possible."""
7
+
8
+ VERIFY_DIFF = """You are a diff verifier. A developer proposed this change.
9
+ Review it for: correctness, edge cases, regressions, test gaps.
10
+ Do NOT suggest alternative approaches unless the proposed
11
+ approach is actively harmful. Flag false fixes with [FALSE-FIX]."""
12
+
13
+ SECOND_OPINION = """You are a second-opinion reviewer. A primary model analyzed
14
+ this diff and provided reasoning. Critique the reasoning:
15
+ - Did it miss edge cases?
16
+ - Did it suggest correct but suboptimal solutions?
17
+ - Did it hallucinate any behavior?
18
+ - Is there a simpler approach?
19
+ Be adversarial. Assume the primary model might be wrong."""
20
+
21
+ COMPARE = """You are a code reviewer from {provider_name}. Review the following
22
+ code/diff for bugs, security issues, performance problems, and style.
23
+ Be concise. Use severity: [CRITICAL], [HIGH], [MEDIUM], [LOW].
24
+ Reference exact line numbers when possible.
25
+
26
+ Your findings will be compared against reviews from other providers.
27
+ Focus on what YOU uniquely catch. Be specific."""
28
+
29
+
30
+ def get_prompt(mode: str, provider_name: str = "") -> str:
31
+ """Return the system prompt for a given review mode."""
32
+ prompts = {
33
+ "raw": RAW,
34
+ "verify-diff": VERIFY_DIFF,
35
+ "second-opinion": SECOND_OPINION,
36
+ "compare": COMPARE.format(provider_name=provider_name),
37
+ }
38
+ return prompts.get(mode, RAW)
File without changes
@@ -0,0 +1,137 @@
1
+ """LLM provider interface with retry, timeout, and structured errors."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import httpx
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ErrorKind(Enum):
14
+ AUTH = "auth"
15
+ RATE_LIMIT = "rate_limit"
16
+ TIMEOUT = "timeout"
17
+ SERVER = "server"
18
+ CLIENT = "client"
19
+ UNKNOWN = "unknown"
20
+
21
+
22
+ @dataclass
23
+ class ReviewResult:
24
+ provider: str
25
+ model: str
26
+ findings: str = ""
27
+ error: str | None = None
28
+ error_kind: ErrorKind | None = None
29
+ tokens_used: int = 0
30
+ latency_ms: float = 0.0
31
+
32
+
33
+ @dataclass
34
+ class ProviderConfig:
35
+ api_key: str = ""
36
+ model: str = ""
37
+ endpoint: str | None = None
38
+ max_tokens: int = 2048
39
+ timeout_sec: float = 60.0
40
+ max_retries: int = 3
41
+
42
+
43
+ class ProviderError(Exception):
44
+ def __init__(self, message: str, kind: ErrorKind = ErrorKind.UNKNOWN) -> None:
45
+ super().__init__(message)
46
+ self.kind = kind
47
+
48
+
49
+ class Provider(ABC):
50
+ def __init__(
51
+ self,
52
+ api_key: str = "",
53
+ model: str = "",
54
+ endpoint: str | None = None,
55
+ max_tokens: int = 2048,
56
+ timeout_sec: float = 60.0,
57
+ max_retries: int = 3,
58
+ ) -> None:
59
+ self.api_key = api_key
60
+ self.model = model
61
+ self.endpoint = endpoint
62
+ self.max_tokens = max_tokens
63
+ self.timeout_sec = timeout_sec
64
+ self.max_retries = max_retries
65
+
66
+ @property
67
+ @abstractmethod
68
+ def name(self) -> str: ...
69
+
70
+ @abstractmethod
71
+ async def _call_api(self, system_prompt: str, user_prompt: str) -> str: ...
72
+
73
+ async def review(self, system_prompt: str, user_prompt: str) -> ReviewResult:
74
+ import time
75
+
76
+ t0 = time.monotonic()
77
+ last_error: Exception | None = None
78
+
79
+ for attempt in range(self.max_retries + 1):
80
+ try:
81
+ text = await asyncio.wait_for(
82
+ self._call_api(system_prompt, user_prompt),
83
+ timeout=self.timeout_sec,
84
+ )
85
+ latency = (time.monotonic() - t0) * 1000
86
+ logger.info(
87
+ "%s review completed in %.0fms (attempt %d)",
88
+ self.name,
89
+ latency,
90
+ attempt + 1,
91
+ )
92
+ return ReviewResult(
93
+ provider=self.name,
94
+ model=self.model,
95
+ findings=text,
96
+ latency_ms=latency,
97
+ )
98
+ except asyncio.TimeoutError:
99
+ last_error = ProviderError(
100
+ f"Request timed out after {self.timeout_sec}s",
101
+ ErrorKind.TIMEOUT,
102
+ )
103
+ logger.warning("%s timeout (attempt %d)", self.name, attempt + 1)
104
+ except httpx.TimeoutException:
105
+ last_error = ProviderError(
106
+ f"HTTP timeout after {self.timeout_sec}s",
107
+ ErrorKind.TIMEOUT,
108
+ )
109
+ logger.warning("%s HTTP timeout (attempt %d)", self.name, attempt + 1)
110
+ except ProviderError as e:
111
+ last_error = e
112
+ if e.kind == ErrorKind.AUTH:
113
+ break
114
+ if e.kind == ErrorKind.RATE_LIMIT and attempt < self.max_retries:
115
+ wait = 2 ** attempt
116
+ logger.warning("%s rate limited, retrying in %ds", self.name, wait)
117
+ await asyncio.sleep(wait)
118
+ continue
119
+ logger.warning("%s error (attempt %d): %s", self.name, attempt + 1, e)
120
+ except Exception as e:
121
+ last_error = e
122
+ logger.warning("%s error (attempt %d): %s", self.name, attempt + 1, e)
123
+
124
+ if attempt < self.max_retries:
125
+ await asyncio.sleep(1 * (attempt + 1))
126
+
127
+ msg = str(last_error) if last_error else "unknown error"
128
+ kind = last_error.kind if isinstance(last_error, ProviderError) else ErrorKind.UNKNOWN
129
+
130
+ return ReviewResult(
131
+ provider=self.name,
132
+ model=self.model,
133
+ findings="",
134
+ error=msg,
135
+ error_kind=kind,
136
+ latency_ms=(time.monotonic() - t0) * 1000,
137
+ )
@@ -0,0 +1,77 @@
1
+ """Claude provider via Anthropic Messages API."""
2
+
3
+ import logging
4
+ import httpx
5
+ from codereview.provider.base import (
6
+ Provider,
7
+ ProviderError,
8
+ ErrorKind,
9
+ )
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ClaudeProvider(Provider):
15
+ @property
16
+ def name(self) -> str:
17
+ return "claude"
18
+
19
+ def _parse_error(self, status: int, body: dict | str) -> ProviderError:
20
+ msg = ""
21
+ if isinstance(body, dict):
22
+ err = body.get("error", {})
23
+ msg = err.get("message", str(body))
24
+ if err.get("type") == "overloaded_error":
25
+ return ProviderError(f"Claude overloaded: {msg}", ErrorKind.RATE_LIMIT)
26
+ else:
27
+ msg = str(body)
28
+
29
+ if status == 401 or status == 403:
30
+ return ProviderError(f"Claude auth error: {msg}", ErrorKind.AUTH)
31
+ if status == 429 or status == 529:
32
+ return ProviderError("Claude rate limited / overloaded", ErrorKind.RATE_LIMIT)
33
+ if status >= 500:
34
+ return ProviderError(f"Claude server error ({status}): {msg}", ErrorKind.SERVER)
35
+ return ProviderError(f"Claude error ({status}): {msg}", ErrorKind.CLIENT)
36
+
37
+ async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
38
+ endpoint = self.endpoint or "https://api.anthropic.com"
39
+ url = f"{endpoint}/v1/messages"
40
+
41
+ headers = {
42
+ "x-api-key": self.api_key,
43
+ "anthropic-version": "2023-06-01",
44
+ "content-type": "application/json",
45
+ }
46
+
47
+ payload = {
48
+ "model": self.model,
49
+ "max_tokens": self.max_tokens,
50
+ "temperature": 0.2,
51
+ "system": system_prompt,
52
+ "messages": [{"role": "user", "content": user_prompt}],
53
+ }
54
+
55
+ async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
56
+ resp = await client.post(url, headers=headers, json=payload)
57
+
58
+ if resp.status_code != 200:
59
+ try:
60
+ body = resp.json()
61
+ except Exception:
62
+ body = resp.text
63
+ raise self._parse_error(resp.status_code, body)
64
+
65
+ data = resp.json()
66
+ content = data.get("content", [])
67
+ if not content:
68
+ raise ProviderError("Claude returned empty response", ErrorKind.SERVER)
69
+
70
+ stop_reason = data.get("stop_reason", "")
71
+ if stop_reason and stop_reason not in ("end_turn", "max_tokens"):
72
+ raise ProviderError(
73
+ f"Claude stopped: {stop_reason}",
74
+ ErrorKind.SERVER,
75
+ )
76
+
77
+ return content[0]["text"]
@@ -0,0 +1,75 @@
1
+ """DeepSeek provider via OpenAI-compatible API."""
2
+
3
+ import logging
4
+ import httpx
5
+ from codereview.provider.base import (
6
+ Provider,
7
+ ProviderError,
8
+ ErrorKind,
9
+ )
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class DeepSeekProvider(Provider):
15
+ @property
16
+ def name(self) -> str:
17
+ return "deepseek"
18
+
19
+ def _parse_error(self, status: int, body: dict | str) -> ProviderError:
20
+ msg = str(body)
21
+ if isinstance(body, dict):
22
+ msg = body.get("error", {}).get("message", str(body))
23
+ code = body.get("error", {}).get("code", "")
24
+ if code == "insufficient_quota":
25
+ return ProviderError(f"DeepSeek quota: {msg}", ErrorKind.RATE_LIMIT)
26
+ if status == 401:
27
+ return ProviderError(f"DeepSeek auth error: {msg}", ErrorKind.AUTH)
28
+ if status == 429:
29
+ return ProviderError("DeepSeek rate limited", ErrorKind.RATE_LIMIT)
30
+ if status >= 500:
31
+ return ProviderError(f"DeepSeek server error ({status}): {msg}", ErrorKind.SERVER)
32
+ return ProviderError(f"DeepSeek error ({status}): {msg}", ErrorKind.CLIENT)
33
+
34
+ async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
35
+ endpoint = self.endpoint or "https://api.deepseek.com"
36
+ url = f"{endpoint}/v1/chat/completions"
37
+
38
+ headers = {
39
+ "Authorization": f"Bearer {self.api_key}",
40
+ "Content-Type": "application/json",
41
+ }
42
+
43
+ payload = {
44
+ "model": self.model,
45
+ "max_tokens": self.max_tokens,
46
+ "temperature": 0.2,
47
+ "messages": [
48
+ {"role": "system", "content": system_prompt},
49
+ {"role": "user", "content": user_prompt},
50
+ ],
51
+ }
52
+
53
+ async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
54
+ resp = await client.post(url, headers=headers, json=payload)
55
+
56
+ if resp.status_code != 200:
57
+ try:
58
+ body = resp.json()
59
+ except Exception:
60
+ body = resp.text
61
+ raise self._parse_error(resp.status_code, body)
62
+
63
+ data = resp.json()
64
+ choices = data.get("choices", [])
65
+ if not choices:
66
+ raise ProviderError("DeepSeek returned empty response", ErrorKind.SERVER)
67
+
68
+ finish_reason = choices[0].get("finish_reason", "")
69
+ if finish_reason and finish_reason not in ("stop", "length"):
70
+ raise ProviderError(
71
+ f"DeepSeek stopped: {finish_reason}",
72
+ ErrorKind.SERVER,
73
+ )
74
+
75
+ return choices[0]["message"]["content"]
@@ -0,0 +1,73 @@
1
+ """Gemini provider via Google AI API."""
2
+
3
+ import logging
4
+ import httpx
5
+ from codereview.provider.base import (
6
+ Provider,
7
+ ProviderError,
8
+ ErrorKind,
9
+ )
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class GeminiProvider(Provider):
15
+ @property
16
+ def name(self) -> str:
17
+ return "gemini"
18
+
19
+ def _parse_error(self, status: int, body: dict | str) -> ProviderError:
20
+ msg = str(body) if isinstance(body, str) else body.get("error", {}).get("message", str(body))
21
+ if status == 401 or status == 403:
22
+ return ProviderError(f"Gemini auth error: {msg}", ErrorKind.AUTH)
23
+ if status == 429:
24
+ return ProviderError("Gemini rate limited", ErrorKind.RATE_LIMIT)
25
+ if status >= 500:
26
+ return ProviderError(f"Gemini server error ({status}): {msg}", ErrorKind.SERVER)
27
+ return ProviderError(f"Gemini error ({status}): {msg}", ErrorKind.CLIENT)
28
+
29
+ async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
30
+ endpoint = self.endpoint or "https://generativelanguage.googleapis.com"
31
+ url = f"{endpoint}/v1beta/models/{self.model}:generateContent"
32
+ params = {}
33
+ if self.api_key:
34
+ params["key"] = self.api_key
35
+
36
+ payload = {
37
+ "systemInstruction": {"parts": [{"text": system_prompt}]},
38
+ "contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
39
+ "generationConfig": {
40
+ "maxOutputTokens": self.max_tokens,
41
+ "temperature": 0.2,
42
+ },
43
+ }
44
+
45
+ async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
46
+ resp = await client.post(url, params=params, json=payload)
47
+
48
+ if resp.status_code != 200:
49
+ try:
50
+ body = resp.json()
51
+ except Exception:
52
+ body = resp.text
53
+ raise self._parse_error(resp.status_code, body)
54
+
55
+ data = resp.json()
56
+ candidates = data.get("candidates", [])
57
+ if not candidates:
58
+ raise ProviderError("Gemini returned empty response", ErrorKind.SERVER)
59
+
60
+ finish_reason = candidates[0].get("finishReason", "")
61
+ if finish_reason and finish_reason not in ("STOP", "MAX_TOKENS"):
62
+ raise ProviderError(
63
+ f"Gemini stopped unexpectedly: {finish_reason}",
64
+ ErrorKind.SERVER,
65
+ )
66
+
67
+ try:
68
+ return candidates[0]["content"]["parts"][0]["text"]
69
+ except (KeyError, IndexError, TypeError):
70
+ raise ProviderError(
71
+ "Gemini returned unexpected response structure",
72
+ ErrorKind.SERVER,
73
+ )
@@ -0,0 +1,73 @@
1
+ """Ollama provider for local models via OpenAI-compatible API."""
2
+
3
+ import logging
4
+ import httpx
5
+ from codereview.provider.base import (
6
+ Provider,
7
+ ProviderError,
8
+ ErrorKind,
9
+ )
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class OllamaProvider(Provider):
15
+ @property
16
+ def name(self) -> str:
17
+ return "ollama"
18
+
19
+ async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
20
+ endpoint = self.endpoint or "http://localhost:11434"
21
+ url = f"{endpoint}/v1/chat/completions"
22
+
23
+ headers = {"Content-Type": "application/json"}
24
+
25
+ payload = {
26
+ "model": self.model,
27
+ "max_tokens": self.max_tokens,
28
+ "temperature": 0.2,
29
+ "messages": [
30
+ {"role": "system", "content": system_prompt},
31
+ {"role": "user", "content": user_prompt},
32
+ ],
33
+ }
34
+
35
+ async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
36
+ try:
37
+ resp = await client.post(url, headers=headers, json=payload)
38
+ except httpx.ConnectError:
39
+ raise ProviderError(
40
+ "Cannot connect to Ollama. Is it running? Try: ollama serve",
41
+ ErrorKind.SERVER,
42
+ )
43
+
44
+ if resp.status_code != 200:
45
+ try:
46
+ body = resp.json()
47
+ msg = body.get("error", {}).get("message", resp.text)
48
+ except Exception:
49
+ msg = resp.text
50
+
51
+ if resp.status_code == 404 and "not found" in msg.lower():
52
+ raise ProviderError(
53
+ f"Model '{self.model}' not found. Pull it: ollama pull {self.model}",
54
+ ErrorKind.CLIENT,
55
+ )
56
+ raise ProviderError(
57
+ f"Ollama error ({resp.status_code}): {msg}",
58
+ ErrorKind.SERVER if resp.status_code >= 500 else ErrorKind.CLIENT,
59
+ )
60
+
61
+ data = resp.json()
62
+ choices = data.get("choices", [])
63
+ if not choices:
64
+ raise ProviderError("Ollama returned empty response", ErrorKind.SERVER)
65
+
66
+ finish_reason = choices[0].get("finish_reason", "")
67
+ if finish_reason and finish_reason not in ("stop", "length"):
68
+ raise ProviderError(
69
+ f"Ollama stopped: {finish_reason}",
70
+ ErrorKind.SERVER,
71
+ )
72
+
73
+ return choices[0]["message"]["content"]
@@ -0,0 +1,81 @@
1
+ """OpenAI provider via Chat Completions API."""
2
+
3
+ import logging
4
+ import httpx
5
+ from codereview.provider.base import (
6
+ Provider,
7
+ ProviderError,
8
+ ErrorKind,
9
+ )
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class OpenAIProvider(Provider):
15
+ @property
16
+ def name(self) -> str:
17
+ return "openai"
18
+
19
+ def _parse_error(self, status: int, body: dict | str) -> ProviderError:
20
+ msg = ""
21
+ if isinstance(body, dict):
22
+ err = body.get("error", {})
23
+ msg = err.get("message", str(body))
24
+ code = err.get("code", "")
25
+ if code == "insufficient_quota":
26
+ if status == 429:
27
+ return ProviderError(f"OpenAI rate/quota: {msg}", ErrorKind.RATE_LIMIT)
28
+ return ProviderError(f"OpenAI quota error (billing): {msg}", ErrorKind.AUTH)
29
+ else:
30
+ msg = str(body)
31
+
32
+ if status == 401:
33
+ return ProviderError(f"OpenAI auth error: {msg}", ErrorKind.AUTH)
34
+ if status == 429:
35
+ return ProviderError(f"OpenAI rate limited: {msg}", ErrorKind.RATE_LIMIT)
36
+ if status >= 500:
37
+ return ProviderError(f"OpenAI server error ({status}): {msg}", ErrorKind.SERVER)
38
+ return ProviderError(f"OpenAI error ({status}): {msg}", ErrorKind.CLIENT)
39
+
40
+ async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
41
+ endpoint = self.endpoint or "https://api.openai.com"
42
+ url = f"{endpoint}/v1/chat/completions"
43
+
44
+ headers = {
45
+ "Authorization": f"Bearer {self.api_key}",
46
+ "Content-Type": "application/json",
47
+ }
48
+
49
+ payload = {
50
+ "model": self.model,
51
+ "max_tokens": self.max_tokens,
52
+ "temperature": 0.2,
53
+ "messages": [
54
+ {"role": "system", "content": system_prompt},
55
+ {"role": "user", "content": user_prompt},
56
+ ],
57
+ }
58
+
59
+ async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
60
+ resp = await client.post(url, headers=headers, json=payload)
61
+
62
+ if resp.status_code != 200:
63
+ try:
64
+ body = resp.json()
65
+ except Exception:
66
+ body = resp.text
67
+ raise self._parse_error(resp.status_code, body)
68
+
69
+ data = resp.json()
70
+ choices = data.get("choices", [])
71
+ if not choices:
72
+ raise ProviderError("OpenAI returned empty response", ErrorKind.SERVER)
73
+
74
+ finish_reason = choices[0].get("finish_reason", "")
75
+ if finish_reason and finish_reason not in ("stop", "length"):
76
+ raise ProviderError(
77
+ f"OpenAI stopped: {finish_reason}",
78
+ ErrorKind.SERVER,
79
+ )
80
+
81
+ return choices[0]["message"]["content"]
@@ -0,0 +1,149 @@
1
+ """Vertex AI provider via Google Cloud platform.
2
+
3
+ Auth via gcloud ADC (Application Default Credentials):
4
+ gcloud auth application-default login
5
+ gcloud config set project YOUR_PROJECT
6
+
7
+ Or set GOOGLE_APPLICATION_CREDENTIALS to a service account key.
8
+ """
9
+
10
+ import asyncio
11
+ import logging
12
+ import httpx
13
+ from codereview.provider.base import (
14
+ Provider,
15
+ ProviderError,
16
+ ErrorKind,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class VertexAIProvider(Provider):
23
+ def __init__(
24
+ self,
25
+ api_key: str = "",
26
+ model: str = "gemini-2.5-pro",
27
+ endpoint: str | None = None,
28
+ max_tokens: int = 2048,
29
+ timeout_sec: float = 60.0,
30
+ max_retries: int = 3,
31
+ ) -> None:
32
+ super().__init__(api_key, model, endpoint, max_tokens, timeout_sec, max_retries)
33
+ self._project: str = ""
34
+ self._location: str = "us-central1"
35
+ self._credentials: object | None = None
36
+
37
+ if self.endpoint:
38
+ parts = self.endpoint.split(",")
39
+ if len(parts) >= 1 and parts[0].strip():
40
+ self._project = parts[0].strip()
41
+ if len(parts) >= 2 and parts[1].strip():
42
+ self._location = parts[1].strip()
43
+
44
+ @property
45
+ def name(self) -> str:
46
+ return "vertexai"
47
+
48
+ async def _get_credentials(self) -> tuple[str, str]:
49
+ if self._credentials is not None and self._project:
50
+ from google.auth.transport.requests import Request
51
+ await asyncio.to_thread(self._credentials.refresh, Request())
52
+ return self._credentials.token, self._project
53
+
54
+ try:
55
+ from google.auth import default
56
+ from google.auth.transport.requests import Request
57
+
58
+ credentials, project = default()
59
+ if not credentials:
60
+ raise ProviderError(
61
+ "No Google Cloud credentials found. Run: gcloud auth application-default login",
62
+ ErrorKind.AUTH,
63
+ )
64
+ if not project and not self._project:
65
+ raise ProviderError(
66
+ "No GCP project configured. Run: gcloud config set project YOUR_PROJECT",
67
+ ErrorKind.AUTH,
68
+ )
69
+
70
+ await asyncio.to_thread(credentials.refresh, Request())
71
+ self._credentials = credentials
72
+ if project:
73
+ self._project = project
74
+
75
+ return credentials.token, self._project
76
+ except ProviderError:
77
+ raise
78
+ except ImportError:
79
+ raise ProviderError(
80
+ "google-auth not installed. Run: pip install codereview-mcp[vertexai]",
81
+ ErrorKind.AUTH,
82
+ )
83
+ except Exception as e:
84
+ raise ProviderError(
85
+ f"Failed to get GCP credentials: {e}",
86
+ ErrorKind.AUTH,
87
+ )
88
+
89
+ async def _call_api(self, system_prompt: str, user_prompt: str) -> str:
90
+ token, project = await self._get_credentials()
91
+
92
+ location = self._location
93
+ url = (
94
+ f"https://{location}-aiplatform.googleapis.com"
95
+ f"/v1/projects/{project}/locations/{location}"
96
+ f"/publishers/google/models/{self.model}:generateContent"
97
+ )
98
+
99
+ headers = {
100
+ "Authorization": f"Bearer {token}",
101
+ "Content-Type": "application/json",
102
+ }
103
+
104
+ payload = {
105
+ "systemInstruction": {"parts": [{"text": system_prompt}]},
106
+ "contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
107
+ "generationConfig": {
108
+ "maxOutputTokens": self.max_tokens,
109
+ "temperature": 0.2,
110
+ },
111
+ }
112
+
113
+ async with httpx.AsyncClient(timeout=self.timeout_sec) as client:
114
+ resp = await client.post(url, headers=headers, json=payload)
115
+
116
+ if resp.status_code != 200:
117
+ try:
118
+ body = resp.json()
119
+ msg = body.get("error", {}).get("message", resp.text)
120
+ except Exception:
121
+ msg = resp.text
122
+
123
+ if resp.status_code == 401 or resp.status_code == 403:
124
+ raise ProviderError(f"Vertex AI auth error: {msg}", ErrorKind.AUTH)
125
+ if resp.status_code == 429:
126
+ raise ProviderError("Vertex AI rate limited", ErrorKind.RATE_LIMIT)
127
+ if resp.status_code >= 500:
128
+ raise ProviderError(f"Vertex AI server error ({resp.status_code}): {msg}", ErrorKind.SERVER)
129
+ raise ProviderError(f"Vertex AI error ({resp.status_code}): {msg}", ErrorKind.CLIENT)
130
+
131
+ data = resp.json()
132
+ candidates = data.get("candidates", [])
133
+ if not candidates:
134
+ raise ProviderError("Vertex AI returned empty response", ErrorKind.SERVER)
135
+
136
+ finish_reason = candidates[0].get("finishReason", "")
137
+ if finish_reason and finish_reason not in ("STOP", "MAX_TOKENS"):
138
+ raise ProviderError(
139
+ f"Vertex AI stopped: {finish_reason}",
140
+ ErrorKind.SERVER,
141
+ )
142
+
143
+ try:
144
+ return candidates[0]["content"]["parts"][0]["text"]
145
+ except (KeyError, IndexError, TypeError):
146
+ raise ProviderError(
147
+ "Vertex AI returned unexpected response structure",
148
+ ErrorKind.SERVER,
149
+ )
File without changes
@@ -0,0 +1,316 @@
1
+ """MCP tool registration and handler for code review."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from mcp.server import Server
9
+ from mcp.types import Tool, TextContent
10
+
11
+ from codereview.config.config import load_config, Config
12
+ from codereview.prompt.prompt import get_prompt
13
+ from codereview.provider.base import Provider, ReviewResult, ErrorKind
14
+ from codereview.provider.gemini import GeminiProvider
15
+ from codereview.provider.claude import ClaudeProvider
16
+ from codereview.provider.openai import OpenAIProvider
17
+ from codereview.provider.deepseek import DeepSeekProvider
18
+ from codereview.provider.ollama import OllamaProvider
19
+ from codereview.provider.vertexai import VertexAIProvider
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ PROVIDER_MAP: dict[str, type[Provider]] = {
24
+ "gemini": GeminiProvider,
25
+ "claude": ClaudeProvider,
26
+ "openai": OpenAIProvider,
27
+ "deepseek": DeepSeekProvider,
28
+ "vertexai": VertexAIProvider,
29
+ "ollama": OllamaProvider,
30
+ }
31
+
32
+ SEVERITY_RE = re.compile(r"\[(CRITICAL|HIGH|MEDIUM|LOW)\]", re.IGNORECASE)
33
+ MAX_CONCURRENT = 5
34
+ _review_semaphore = asyncio.Semaphore(MAX_CONCURRENT)
35
+
36
+
37
+ def _make_provider(name: str, cfg: Config) -> Provider | None:
38
+ if name not in cfg.providers:
39
+ logger.warning("Provider '%s' not in config", name)
40
+ return None
41
+ pc = cfg.providers[name]
42
+ cls = PROVIDER_MAP.get(name)
43
+ if cls is None:
44
+ return None
45
+ return cls(
46
+ api_key=pc.api_key,
47
+ model=pc.model or cls.__name__.replace("Provider", "").lower(),
48
+ endpoint=pc.endpoint,
49
+ max_tokens=cfg.defaults.max_tokens,
50
+ timeout_sec=cfg.defaults.timeout_sec,
51
+ max_retries=cfg.defaults.max_retries,
52
+ )
53
+
54
+
55
+ def _read_files(files: list[str]) -> str:
56
+ parts: list[str] = []
57
+ for f in files:
58
+ path = Path(f)
59
+ if path.exists() and path.is_file():
60
+ try:
61
+ content = path.read_text()
62
+ parts.append(f"--- {f} ---\n{content}")
63
+ except UnicodeDecodeError:
64
+ parts.append(f"--- {f} ---\n[BINARY FILE — skipped]")
65
+ logger.warning("Binary file skipped: %s", f)
66
+ else:
67
+ parts.append(f"--- {f} ---\n[FILE NOT FOUND]")
68
+ logger.warning("File not found: %s", f)
69
+ return "\n\n".join(parts)
70
+
71
+
72
+ def _build_user_prompt(
73
+ mode: str,
74
+ files_content: str,
75
+ diff: str = "",
76
+ explanation: str = "",
77
+ focus: str = "all",
78
+ ) -> str:
79
+ base = f"Review the following:\n\n{files_content}"
80
+
81
+ if diff:
82
+ base += f"\n\n## Proposed Diff\n\n{diff}"
83
+
84
+ if mode == "second-opinion" and explanation:
85
+ base += f"\n\n## Primary Model's Reasoning\n\n{explanation}"
86
+ base += "\n\nCritique the reasoning above. What did it miss?"
87
+
88
+ if mode == "verify-diff":
89
+ base += "\n\nIs this diff correct? Does it introduce bugs, regressions, or edge case failures?"
90
+
91
+ if focus and focus != "all":
92
+ base += f"\n\nFocus your review on: {focus}."
93
+
94
+ return base
95
+
96
+
97
+ def _count_severities(text: str) -> dict[str, int]:
98
+ counts: dict[str, int] = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "total": 0}
99
+ for match in SEVERITY_RE.finditer(text):
100
+ sev = match.group(1).upper()
101
+ counts[sev] += 1
102
+ counts["total"] += 1
103
+ return counts
104
+
105
+
106
+ def _format_single(result: ReviewResult) -> str:
107
+ header = f"## Code Review — {result.provider} ({result.model})"
108
+ timing = f"*{result.latency_ms:.0f}ms*"
109
+ return f"{header} {timing}\n\n{result.findings}"
110
+
111
+
112
+ def _format_compare(explanation: str, results: list[ReviewResult]) -> str:
113
+ if not results:
114
+ return "No results."
115
+
116
+ lines = ["## Multi-Provider Code Review\n"]
117
+
118
+ if explanation:
119
+ lines.append(f"**Primary reasoning:** {explanation[:300]}\n")
120
+
121
+ # Individual reviews
122
+ for r in results:
123
+ if r.error:
124
+ lines.append(f"### {r.provider} — ERROR ({r.error_kind.value if r.error_kind else 'unknown'})")
125
+ lines.append(f"```\n{r.error}\n```\n")
126
+ else:
127
+ lines.append(f"### {r.provider} ({r.model}) — {r.latency_ms:.0f}ms\n")
128
+ lines.append(r.findings)
129
+ lines.append("")
130
+
131
+ # Severity summary table
132
+ lines.append("---\n")
133
+ lines.append("## Summary\n")
134
+
135
+ sevs: dict[str, dict[str, int]] = {}
136
+ for r in results:
137
+ if not r.error:
138
+ sevs[r.provider] = _count_severities(r.findings)
139
+
140
+ all_providers = list(sevs.keys())
141
+ if all_providers:
142
+ header = "| Provider | CRITICAL | HIGH | MEDIUM | LOW | Total |"
143
+ sep = "|----------|----------|------|--------|-----|-------|"
144
+ lines.append(header)
145
+ lines.append(sep)
146
+ for p in all_providers:
147
+ c = sevs[p]
148
+ row = f"| {p} | {c.get('CRITICAL', 0)} | {c.get('HIGH', 0)} | {c.get('MEDIUM', 0)} | {c.get('LOW', 0)} | {c['total']} |"
149
+ lines.append(row)
150
+
151
+ lines.append("")
152
+ total_issues = sum(sevs[p]["total"] for p in all_providers)
153
+ provider_count = len(all_providers)
154
+ avg = total_issues / provider_count if provider_count > 0 else 0
155
+ lines.append(
156
+ f"**{total_issues} total issues across {provider_count} providers "
157
+ f"(avg {avg:.1f}/provider).** "
158
+ )
159
+
160
+ # Timing table
161
+ lines.append("\n### Latency")
162
+ lines.append("| Provider | Time |")
163
+ lines.append("|----------|------|")
164
+ for r in results:
165
+ if r.error:
166
+ lines.append(f"| {r.provider} | ⚠ {r.latency_ms:.0f}ms (failed) |")
167
+ else:
168
+ lines.append(f"| {r.provider} | {r.latency_ms:.0f}ms |")
169
+
170
+ return "\n".join(lines)
171
+
172
+
173
+ async def _run_single_review(
174
+ mode: str,
175
+ provider_name: str,
176
+ system_prompt: str,
177
+ user_prompt: str,
178
+ cfg: Config,
179
+ ) -> ReviewResult:
180
+ provider = _make_provider(provider_name, cfg)
181
+ if provider is None:
182
+ return ReviewResult(
183
+ provider=provider_name,
184
+ model="?",
185
+ error=f"Provider '{provider_name}' not configured. Add it to config.yaml.",
186
+ error_kind=ErrorKind.AUTH,
187
+ )
188
+ logger.info("Running review with %s (mode=%s)", provider_name, mode)
189
+ async with _review_semaphore:
190
+ return await provider.review(system_prompt, user_prompt)
191
+
192
+
193
+ def register_tools(server: Server, config_path: str | None = None) -> None:
194
+ cfg = load_config(config_path)
195
+
196
+ @server.list_tools()
197
+ async def list_tools() -> list[Tool]:
198
+ return [
199
+ Tool(
200
+ name="code_review",
201
+ description=(
202
+ "Review code using a secondary LLM provider. "
203
+ "Supports raw review, diff verification, second-opinion critique, "
204
+ "and multi-provider comparison with severity-tracking matrix."
205
+ ),
206
+ inputSchema={
207
+ "type": "object",
208
+ "properties": {
209
+ "files": {
210
+ "type": "array",
211
+ "items": {"type": "string"},
212
+ "description": "File paths or inline code snippets to review.",
213
+ },
214
+ "diff": {
215
+ "type": "string",
216
+ "description": "Git diff or proposed change to verify.",
217
+ },
218
+ "explanation": {
219
+ "type": "string",
220
+ "description": "Primary model's reasoning to critique (second-opinion mode).",
221
+ },
222
+ "provider": {
223
+ "type": "string",
224
+ "enum": list(PROVIDER_MAP.keys()),
225
+ "description": "Which LLM to use for review. Defaults to config default.",
226
+ },
227
+ "providers": {
228
+ "type": "array",
229
+ "items": {"type": "string", "enum": list(PROVIDER_MAP.keys())},
230
+ "description": "List of providers for compare mode. Runs all concurrently.",
231
+ },
232
+ "mode": {
233
+ "type": "string",
234
+ "enum": ["raw", "verify-diff", "second-opinion", "compare"],
235
+ "description": "Review mode.",
236
+ },
237
+ "focus": {
238
+ "type": "string",
239
+ "enum": ["bugs", "security", "performance", "style", "all"],
240
+ "description": "Review focus area.",
241
+ },
242
+ },
243
+ "required": ["files", "mode"],
244
+ },
245
+ )
246
+ ]
247
+
248
+ @server.call_tool()
249
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
250
+ if name != "code_review":
251
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
252
+
253
+ files: list[str] = arguments.get("files", [])
254
+ if isinstance(files, str):
255
+ files = [files]
256
+ diff = arguments.get("diff", "")
257
+ explanation = arguments.get("explanation", "")
258
+ provider_name = arguments.get("provider", cfg.default_provider)
259
+ providers_list = arguments.get("providers", [])
260
+ if isinstance(providers_list, str):
261
+ providers_list = [s.strip() for s in providers_list.split(",") if s.strip()]
262
+ mode = arguments.get("mode", "raw")
263
+ focus = arguments.get("focus", "all")
264
+
265
+ if not files:
266
+ return [TextContent(type="text", text="Error: 'files' is required.")]
267
+
268
+ if mode == "compare" and not providers_list:
269
+ return [TextContent(type="text", text="Error: 'providers' list required for compare mode.")]
270
+
271
+ files_content = _read_files(files)
272
+ user_prompt = _build_user_prompt(mode, files_content, diff, explanation, focus)
273
+
274
+ # Compare mode: run multiple providers concurrently
275
+ if mode == "compare" and providers_list:
276
+ tasks = [
277
+ _run_single_review(
278
+ "compare",
279
+ p,
280
+ get_prompt("compare", p),
281
+ user_prompt,
282
+ cfg,
283
+ )
284
+ for p in providers_list
285
+ ]
286
+ results: list[ReviewResult] = await asyncio.gather(*tasks, return_exceptions=True)
287
+ parsed: list[ReviewResult] = []
288
+ for r in results:
289
+ if isinstance(r, Exception):
290
+ parsed.append(ReviewResult(
291
+ provider="unknown",
292
+ model="?",
293
+ error=str(r),
294
+ ))
295
+ else:
296
+ parsed.append(r)
297
+ output = _format_compare(explanation, parsed)
298
+ return [TextContent(type="text", text=output)]
299
+
300
+ # Single-provider mode
301
+ system_prompt = get_prompt(mode, provider_name)
302
+ result = await _run_single_review(mode, provider_name, system_prompt, user_prompt, cfg)
303
+
304
+ if result.error:
305
+ error_text = (
306
+ f"## Code Review — ERROR\n\n"
307
+ f"**Provider:** {result.provider}\n"
308
+ f"**Error:** {result.error}\n"
309
+ )
310
+ if result.error_kind == ErrorKind.AUTH:
311
+ error_text += "\nCheck your API key in `~/.config/codereview/config.yaml`."
312
+ elif result.error_kind == ErrorKind.RATE_LIMIT:
313
+ error_text += "\nRate limited. Wait and retry."
314
+ return [TextContent(type="text", text=error_text)]
315
+
316
+ return [TextContent(type="text", text=_format_single(result))]
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: secondlook
3
+ Version: 0.1.0
4
+ Summary: Multi-provider code review via MCP. Get a second look on your code from any LLM.
5
+ Author: Cleverson Lopes Ledur
6
+ License: MIT
7
+ Keywords: ai,claude,code-review,deepseek,gemini,llm,mcp,multi-provider,openai,second-opinion
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: httpx>=0.28
15
+ Requires-Dist: mcp>=1.0
16
+ Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: pyyaml>=6.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.12; extra == 'dev'
21
+ Provides-Extra: vertexai
22
+ Requires-Dist: google-auth>=2.0; extra == 'vertexai'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # secondlook
26
+
27
+ Multi-provider code review via MCP. Get a second look on your code from any LLM.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ uvx secondlook
33
+ ```
34
+
35
+ Add to Claude Code / Cursor MCP config:
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "codereview": {
41
+ "command": "uvx",
42
+ "args": ["secondlook"]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ Create `~/.config/codereview/config.yaml`:
51
+
52
+ ```yaml
53
+ default_provider: gemini
54
+
55
+ providers:
56
+ gemini:
57
+ model: gemini-2.5-pro
58
+ api_key: ${GEMINI_API_KEY}
59
+ claude:
60
+ model: claude-sonnet-4-20250514
61
+ api_key: ${ANTHROPIC_API_KEY}
62
+ ```
63
+
64
+ Then in your editor:
65
+
66
+ ```
67
+ > code_review files:["src/auth.go"] provider:"gemini"
68
+ > code_review files:["src/auth.go"] providers:["gemini","claude","deepseek"] mode:"compare"
69
+ ```
70
+
71
+ ## Modes
72
+
73
+ | Mode | Use case |
74
+ |---|---|
75
+ | `raw` | Fresh review from a secondary LLM |
76
+ | `verify-diff` | "Is Claude's proposed change correct?" |
77
+ | `second-opinion` | "Critique Claude's reasoning on this fix" |
78
+ | `compare` | Run 2+ models, merge findings, show agreement matrix |
79
+
80
+ ## Providers
81
+
82
+ Gemini | Claude | OpenAI | DeepSeek | Vertex AI | Ollama (local)
@@ -0,0 +1,20 @@
1
+ codereview/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ codereview/main.py,sha256=L_ZaWtaGB9Riiuxism8e47isxaVjpAb6VKtBm9NfopU,953
3
+ codereview/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ codereview/config/config.py,sha256=nKTw30awgfLbar7aL-_1G7qJGGMBjf7ESfsTlVXPpRc,2020
5
+ codereview/prompt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ codereview/prompt/prompt.py,sha256=X_CQhOYwYnOmPu7PcpGcwBS7rFTJ79hLVvdZGvQr3g0,1621
7
+ codereview/provider/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ codereview/provider/base.py,sha256=t-CD4_F5C_H7NUNINPl8uqf5eNUbXYWswZ1gAm94LHk,4266
9
+ codereview/provider/claude.py,sha256=NOx9wBDE3U8mytf67YFGfSAj-m5C17_ZZBz55IFB-SU,2650
10
+ codereview/provider/deepseek.py,sha256=dIV0hKEUI1X0XlHPJOOppI_Dmtiyjzx05WitjL7-6Ug,2661
11
+ codereview/provider/gemini.py,sha256=0ob5Bo1_Lc5E4vNr-JM2ZEyuowbEDYBAWNAELvEmEos,2726
12
+ codereview/provider/ollama.py,sha256=YbsTbGzJrg6YFgGatyfZRDwkfydQsXA_tGtsJk4jFmU,2508
13
+ codereview/provider/openai.py,sha256=5DdV7YGTq9p8db6S8Fjin7U_CJuzOCt_H4q3ie6vCYc,2822
14
+ codereview/provider/vertexai.py,sha256=XwPIVeJBD3MhTGKm9C_Z_kwFgpqRWL90J2Ky8mB8jaU,5422
15
+ codereview/tool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ codereview/tool/review.py,sha256=LkTA4GuWEhY210Z_Ks02V3Ox-hSyQ129hBtxzLa7MTc,11723
17
+ secondlook-0.1.0.dist-info/METADATA,sha256=afGl-AAnuI_D687CQajTbttEGonuUk73KOF_J51iOVU,1976
18
+ secondlook-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
19
+ secondlook-0.1.0.dist-info/entry_points.txt,sha256=nX04tGYhvgrGojCDZNk-A2ZHmTkDxRXjgz5FdCVeDZM,52
20
+ secondlook-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ secondlook = codereview.main:main