kodelens-cli 0.1.1__tar.gz
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.
- kodelens_cli-0.1.1/.gitignore +18 -0
- kodelens_cli-0.1.1/Makefile +30 -0
- kodelens_cli-0.1.1/PKG-INFO +8 -0
- kodelens_cli-0.1.1/README.md +0 -0
- kodelens_cli-0.1.1/pyproject.toml +23 -0
- kodelens_cli-0.1.1/src/kodelens_cli/__init__.py +1 -0
- kodelens_cli-0.1.1/src/kodelens_cli/__main__.py +9 -0
- kodelens_cli-0.1.1/src/kodelens_cli/auth.py +152 -0
- kodelens_cli-0.1.1/src/kodelens_cli/cli.py +420 -0
- kodelens_cli-0.1.1/src/kodelens_cli/client.py +115 -0
- kodelens_cli-0.1.1/src/kodelens_cli/config.py +65 -0
- kodelens_cli-0.1.1/uv.lock +161 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
.PHONY: help build publish publish-minor publish-major version
|
|
2
|
+
|
|
3
|
+
help: ## Show this help
|
|
4
|
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
|
5
|
+
|
|
6
|
+
version: ## Show current package version
|
|
7
|
+
@grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/'
|
|
8
|
+
|
|
9
|
+
build: ## Build wheel + sdist
|
|
10
|
+
rm -rf dist
|
|
11
|
+
uv build
|
|
12
|
+
|
|
13
|
+
publish: ## Bump patch version, build, and publish to PyPI
|
|
14
|
+
uvx hatch version patch
|
|
15
|
+
rm -rf dist
|
|
16
|
+
uv build
|
|
17
|
+
uv publish
|
|
18
|
+
@echo "Published $$(grep '^version = ' pyproject.toml | sed 's/version = \"\(.*\)\"/\1/')"
|
|
19
|
+
|
|
20
|
+
publish-minor: ## Bump minor version, build, and publish to PyPI
|
|
21
|
+
uvx hatch version minor
|
|
22
|
+
rm -rf dist
|
|
23
|
+
uv build
|
|
24
|
+
uv publish
|
|
25
|
+
|
|
26
|
+
publish-major: ## Bump major version, build, and publish to PyPI
|
|
27
|
+
uvx hatch version major
|
|
28
|
+
rm -rf dist
|
|
29
|
+
uv build
|
|
30
|
+
uv publish
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kodelens-cli"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "CLI for KodeLens — surface code health issues in your terminal and AI coding tools"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.1",
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
"rich>=13.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
kodelens = "kodelens_cli.__main__:main"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.version]
|
|
20
|
+
path = "src/kodelens_cli/__init__.py"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/kodelens_cli"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Login flow: PKCE browser-based auth.
|
|
3
|
+
|
|
4
|
+
1. Generate code_verifier + code_challenge
|
|
5
|
+
2. Start local HTTP server on ephemeral port
|
|
6
|
+
3. Open browser to {KODELENS_URL}/cli/auth?code_challenge=...&redirect_uri=...
|
|
7
|
+
4. KodeLens authenticates user, creates ApiKey, redirects to localhost callback
|
|
8
|
+
5. Extract token from callback, save to credentials file
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import http.server
|
|
16
|
+
import secrets
|
|
17
|
+
import threading
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import webbrowser
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from kodelens_cli.config import clear_credentials, get_url, save_credentials
|
|
23
|
+
|
|
24
|
+
_AUTH_SUCCESS_HTML = b"""<!DOCTYPE html>
|
|
25
|
+
<html lang="en">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="UTF-8">
|
|
28
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
29
|
+
<title>KodeLens \xe2\x80\x94 Authenticated</title>
|
|
30
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
31
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
32
|
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Roboto:wght@300;400&display=swap" rel="stylesheet">
|
|
33
|
+
<style>
|
|
34
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
35
|
+
html, body {
|
|
36
|
+
height: 100%;
|
|
37
|
+
background-color: #181925;
|
|
38
|
+
color: #ffffff;
|
|
39
|
+
font-family: "Roboto", sans-serif;
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
}
|
|
44
|
+
.container {
|
|
45
|
+
text-align: center;
|
|
46
|
+
padding: 2rem;
|
|
47
|
+
max-width: 560px;
|
|
48
|
+
}
|
|
49
|
+
.check {
|
|
50
|
+
width: 56px;
|
|
51
|
+
height: 56px;
|
|
52
|
+
border-radius: 50%;
|
|
53
|
+
background: rgba(70, 223, 150, 0.12);
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: center;
|
|
57
|
+
margin: 0 auto 2rem;
|
|
58
|
+
}
|
|
59
|
+
.check svg {
|
|
60
|
+
width: 28px;
|
|
61
|
+
height: 28px;
|
|
62
|
+
stroke: #46df96;
|
|
63
|
+
stroke-width: 2.5;
|
|
64
|
+
fill: none;
|
|
65
|
+
stroke-linecap: round;
|
|
66
|
+
stroke-linejoin: round;
|
|
67
|
+
}
|
|
68
|
+
h1 {
|
|
69
|
+
font-family: "Space Grotesk", sans-serif;
|
|
70
|
+
font-size: 2.5rem;
|
|
71
|
+
font-weight: 600;
|
|
72
|
+
color: #46df96;
|
|
73
|
+
letter-spacing: -0.02em;
|
|
74
|
+
margin-bottom: 1rem;
|
|
75
|
+
}
|
|
76
|
+
p {
|
|
77
|
+
font-size: 1.05rem;
|
|
78
|
+
font-weight: 300;
|
|
79
|
+
color: rgba(255, 255, 255, 0.6);
|
|
80
|
+
line-height: 1.6;
|
|
81
|
+
}
|
|
82
|
+
</style>
|
|
83
|
+
</head>
|
|
84
|
+
<body>
|
|
85
|
+
<div class="container">
|
|
86
|
+
<div class="check">
|
|
87
|
+
<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
|
88
|
+
</div>
|
|
89
|
+
<h1>You’re all set.</h1>
|
|
90
|
+
<p>KodeLens CLI is authenticated.<br>You can close this tab and return to the terminal.</p>
|
|
91
|
+
</div>
|
|
92
|
+
</body>
|
|
93
|
+
</html>"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _generate_pkce() -> tuple[str, str]:
|
|
97
|
+
verifier = secrets.token_urlsafe(64)
|
|
98
|
+
challenge = (
|
|
99
|
+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
100
|
+
.rstrip(b"=")
|
|
101
|
+
.decode()
|
|
102
|
+
)
|
|
103
|
+
return verifier, challenge
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def login() -> str:
|
|
107
|
+
"""Opens browser for KodeLens login, waits for callback, returns API key."""
|
|
108
|
+
_, challenge = _generate_pkce()
|
|
109
|
+
token_holder: dict[str, Optional[str]] = {"token": None}
|
|
110
|
+
done = threading.Event()
|
|
111
|
+
|
|
112
|
+
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
113
|
+
def do_GET(self):
|
|
114
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
115
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
116
|
+
token_holder["token"] = (params.get("token") or [None])[0]
|
|
117
|
+
self.send_response(200)
|
|
118
|
+
self.send_header("Content-Type", "text/html")
|
|
119
|
+
self.end_headers()
|
|
120
|
+
self.wfile.write(_AUTH_SUCCESS_HTML)
|
|
121
|
+
done.set()
|
|
122
|
+
|
|
123
|
+
def log_message(self, *args):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
server = http.server.HTTPServer(("127.0.0.1", 0), CallbackHandler)
|
|
127
|
+
port = server.server_address[1]
|
|
128
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
129
|
+
|
|
130
|
+
auth_url = (
|
|
131
|
+
f"{get_url()}/cli/auth/"
|
|
132
|
+
f"?code_challenge={challenge}&redirect_uri={urllib.parse.quote(redirect_uri)}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
thread = threading.Thread(target=lambda: server.handle_request(), daemon=True)
|
|
136
|
+
thread.start()
|
|
137
|
+
|
|
138
|
+
webbrowser.open(auth_url)
|
|
139
|
+
|
|
140
|
+
done.wait(timeout=120)
|
|
141
|
+
server.server_close()
|
|
142
|
+
|
|
143
|
+
token = token_holder["token"]
|
|
144
|
+
if not token:
|
|
145
|
+
raise TimeoutError("Login timed out. Please try again.")
|
|
146
|
+
|
|
147
|
+
save_credentials(token)
|
|
148
|
+
return token
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def logout() -> None:
|
|
152
|
+
clear_credentials()
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.syntax import Syntax
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from kodelens_cli.auth import login, logout
|
|
16
|
+
from kodelens_cli.client import ApiError, AuthError, KodeLensClient, NotFoundError
|
|
17
|
+
from kodelens_cli.config import detect_remote_url, get_api_key, get_repo_id_override
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
err_console = Console(stderr=True)
|
|
21
|
+
|
|
22
|
+
SEVERITY_COLOURS = {
|
|
23
|
+
"critical": "bold red",
|
|
24
|
+
"high": "red",
|
|
25
|
+
"medium": "yellow",
|
|
26
|
+
"low": "blue",
|
|
27
|
+
"info": "dim",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_client() -> KodeLensClient:
|
|
32
|
+
if not get_api_key():
|
|
33
|
+
err_console.print(
|
|
34
|
+
"[red]Not authenticated.[/red] Run [bold]kodelens login[/bold] "
|
|
35
|
+
"or set [bold]KODELENS_API_KEY[/bold]."
|
|
36
|
+
)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
return KodeLensClient()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_repo(client: KodeLensClient, debug: bool = False) -> dict:
|
|
42
|
+
repo_id = get_repo_id_override()
|
|
43
|
+
if repo_id:
|
|
44
|
+
if debug:
|
|
45
|
+
err_console.print(f"[dim]Using KODELENS_REPO_ID={repo_id}[/dim]")
|
|
46
|
+
return client.find_repository(repo_id=repo_id)
|
|
47
|
+
remote_url = detect_remote_url()
|
|
48
|
+
if debug:
|
|
49
|
+
err_console.print(f"[dim]Detected remote URL: {remote_url}[/dim]")
|
|
50
|
+
if remote_url:
|
|
51
|
+
return client.find_repository(remote_url=remote_url)
|
|
52
|
+
err_console.print(
|
|
53
|
+
"[red]Could not detect repository.[/red] "
|
|
54
|
+
"Set [bold]KODELENS_REPO_ID[/bold] or run from inside a git repository."
|
|
55
|
+
)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_current_commit() -> str | None:
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", "rev-parse", "HEAD"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
timeout=5,
|
|
66
|
+
)
|
|
67
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _warn_if_stale(repo: dict) -> None:
|
|
73
|
+
latest = repo.get("latestCommitSha")
|
|
74
|
+
current = _get_current_commit()
|
|
75
|
+
if not latest or not current:
|
|
76
|
+
return
|
|
77
|
+
if not current.startswith(latest[:12]) and not latest.startswith(current[:12]):
|
|
78
|
+
err_console.print(
|
|
79
|
+
f"[yellow]Note: latest scan was on {latest[:8]}, "
|
|
80
|
+
f"current HEAD is {current[:8]} — results may be outdated.[/yellow]"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _lang_from_path(file_path: str | None) -> str:
|
|
85
|
+
if not file_path:
|
|
86
|
+
return "text"
|
|
87
|
+
ext = file_path.rsplit(".", 1)[-1].lower() if "." in file_path else ""
|
|
88
|
+
return {
|
|
89
|
+
"py": "python",
|
|
90
|
+
"js": "javascript",
|
|
91
|
+
"ts": "typescript",
|
|
92
|
+
"tsx": "tsx",
|
|
93
|
+
"jsx": "jsx",
|
|
94
|
+
"rb": "ruby",
|
|
95
|
+
"go": "go",
|
|
96
|
+
"java": "java",
|
|
97
|
+
"cs": "csharp",
|
|
98
|
+
"rs": "rust",
|
|
99
|
+
"php": "php",
|
|
100
|
+
"kt": "kotlin",
|
|
101
|
+
"swift": "swift",
|
|
102
|
+
}.get(ext, "text")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@click.group()
|
|
106
|
+
def cli():
|
|
107
|
+
"""KodeLens CLI — surface code health issues in your terminal."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@cli.command(name="login")
|
|
111
|
+
def login_cmd():
|
|
112
|
+
"""Authenticate with KodeLens via browser."""
|
|
113
|
+
console.print("Opening browser for authentication...")
|
|
114
|
+
try:
|
|
115
|
+
login()
|
|
116
|
+
console.print("[green]Authenticated successfully.[/green]")
|
|
117
|
+
except TimeoutError as e:
|
|
118
|
+
err_console.print(f"[red]{e}[/red]")
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@cli.command(name="logout")
|
|
123
|
+
def logout_cmd():
|
|
124
|
+
"""Clear stored credentials."""
|
|
125
|
+
logout()
|
|
126
|
+
console.print("Logged out.")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@cli.group()
|
|
130
|
+
def issues():
|
|
131
|
+
"""Commands for browsing and inspecting code issues."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@issues.command(name="list")
|
|
135
|
+
@click.option(
|
|
136
|
+
"--file", "file_path", default=None, help="Filter to a specific file path."
|
|
137
|
+
)
|
|
138
|
+
@click.option(
|
|
139
|
+
"--severity",
|
|
140
|
+
default="high,critical",
|
|
141
|
+
show_default=True,
|
|
142
|
+
help="Comma-separated severities, or 'all'.",
|
|
143
|
+
)
|
|
144
|
+
@click.option(
|
|
145
|
+
"--type",
|
|
146
|
+
"obs_type",
|
|
147
|
+
default=None,
|
|
148
|
+
help="Filter by type: security, complexity, etc.",
|
|
149
|
+
)
|
|
150
|
+
@click.option("--limit", default=50, show_default=True)
|
|
151
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON.")
|
|
152
|
+
@click.option("--debug", is_flag=True, hidden=True)
|
|
153
|
+
def issues_list(file_path, severity, obs_type, limit, as_json, debug):
|
|
154
|
+
"""List active issues for the current repository."""
|
|
155
|
+
client = _make_client()
|
|
156
|
+
try:
|
|
157
|
+
repo = _resolve_repo(client, debug=debug)
|
|
158
|
+
sev_param = None if severity == "all" else severity
|
|
159
|
+
data = client.repository_observations(
|
|
160
|
+
repo["id"],
|
|
161
|
+
severity=sev_param,
|
|
162
|
+
obs_type=obs_type,
|
|
163
|
+
file_path=file_path,
|
|
164
|
+
per_page=limit,
|
|
165
|
+
)
|
|
166
|
+
except (AuthError, NotFoundError, ApiError) as e:
|
|
167
|
+
err_console.print(f"[red]{e}[/red]")
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
if as_json:
|
|
171
|
+
console.print_json(json.dumps(data))
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
_warn_if_stale(repo)
|
|
175
|
+
|
|
176
|
+
results = data.get("results", [])
|
|
177
|
+
_print_table(results, data.get("total", 0))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@issues.command(name="detail")
|
|
181
|
+
@click.option("--id", "observation_id", required=True, type=int, help="Issue ID.")
|
|
182
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON.")
|
|
183
|
+
def issues_detail(observation_id, as_json):
|
|
184
|
+
"""Show full detail for a single issue."""
|
|
185
|
+
client = _make_client()
|
|
186
|
+
try:
|
|
187
|
+
data = client.observation_detail(observation_id)
|
|
188
|
+
except (AuthError, NotFoundError, ApiError) as e:
|
|
189
|
+
err_console.print(f"[red]{e}[/red]")
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
|
|
192
|
+
if as_json:
|
|
193
|
+
console.print_json(json.dumps(data))
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
_print_issue_detail(data)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@issues.command(name="dismiss")
|
|
200
|
+
@click.option("--id", "observation_id", required=True, type=int, help="Issue ID.")
|
|
201
|
+
@click.option("--message", required=True, help="Reason for dismissal.")
|
|
202
|
+
def issues_dismiss(observation_id, message):
|
|
203
|
+
"""Dismiss an issue with a required reason."""
|
|
204
|
+
console.print(f"Dismissing issue [bold]#{observation_id}[/bold]")
|
|
205
|
+
console.print(f"Reason: [dim]{message}[/dim]")
|
|
206
|
+
if not click.confirm(f"Confirm dismiss issue #{observation_id}?", default=False):
|
|
207
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
208
|
+
return
|
|
209
|
+
client = _make_client()
|
|
210
|
+
try:
|
|
211
|
+
client.dismiss_observation(observation_id, reason=message)
|
|
212
|
+
except (AuthError, NotFoundError, ApiError) as e:
|
|
213
|
+
err_console.print(f"[red]{e}[/red]")
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
console.print(f"[green]Issue #{observation_id} dismissed.[/green]")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _print_table(results: list, total: int) -> None:
|
|
219
|
+
if not results:
|
|
220
|
+
console.print("[green]No issues found.[/green]")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
table = Table(show_header=True, header_style="bold", show_lines=False)
|
|
224
|
+
table.add_column("ID", justify="right", width=6)
|
|
225
|
+
table.add_column("Severity", width=10)
|
|
226
|
+
table.add_column("Type", width=12)
|
|
227
|
+
table.add_column("File", overflow="fold")
|
|
228
|
+
table.add_column("Line", justify="right", width=6)
|
|
229
|
+
table.add_column("Title")
|
|
230
|
+
|
|
231
|
+
for obs in results:
|
|
232
|
+
sev = obs["severity"].lower()
|
|
233
|
+
colour = SEVERITY_COLOURS.get(sev, "")
|
|
234
|
+
sev_str = (
|
|
235
|
+
f"[{colour}]{obs['severity']}[/{colour}]" if colour else obs["severity"]
|
|
236
|
+
)
|
|
237
|
+
file_str = obs.get("filePath") or ""
|
|
238
|
+
sl = obs.get("startLine")
|
|
239
|
+
line_str = str(sl) if sl is not None else ""
|
|
240
|
+
table.add_row(
|
|
241
|
+
str(obs["id"]),
|
|
242
|
+
sev_str,
|
|
243
|
+
obs.get("type", ""),
|
|
244
|
+
file_str,
|
|
245
|
+
line_str,
|
|
246
|
+
obs.get("title", ""),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
console.print(table)
|
|
250
|
+
if total > len(results):
|
|
251
|
+
console.print(
|
|
252
|
+
f"[dim]Showing {len(results)} of {total}. Use --limit to see more.[/dim]"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _print_issue_detail(data: dict) -> None:
|
|
257
|
+
sev = data.get("severity", "").lower()
|
|
258
|
+
colour = SEVERITY_COLOURS.get(sev, "white")
|
|
259
|
+
obs_type = data.get("type", "")
|
|
260
|
+
issue_id = data.get("id", "")
|
|
261
|
+
|
|
262
|
+
header = Text()
|
|
263
|
+
header.append(f"Issue #{issue_id}", style="bold")
|
|
264
|
+
header.append(" · ")
|
|
265
|
+
header.append(data.get("severity", ""), style=colour)
|
|
266
|
+
header.append(" · ")
|
|
267
|
+
header.append(obs_type)
|
|
268
|
+
|
|
269
|
+
repo_name = (data.get("repository") or {}).get("name", "")
|
|
270
|
+
file_path = data.get("filePath") or ""
|
|
271
|
+
start_line = data.get("startLine")
|
|
272
|
+
end_line = data.get("endLine")
|
|
273
|
+
start_col = data.get("startColumn")
|
|
274
|
+
end_col = data.get("endColumn")
|
|
275
|
+
|
|
276
|
+
lines_str = (
|
|
277
|
+
f"{start_line}–{end_line}"
|
|
278
|
+
if start_line is not None and end_line is not None
|
|
279
|
+
else (str(start_line) if start_line is not None else "")
|
|
280
|
+
)
|
|
281
|
+
cols_str = (
|
|
282
|
+
f"{start_col}–{end_col}"
|
|
283
|
+
if start_col is not None and end_col is not None and (start_col or end_col)
|
|
284
|
+
else ""
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
meta_lines = []
|
|
288
|
+
if repo_name:
|
|
289
|
+
meta_lines.append(f"[dim]Repo [/dim] {repo_name}")
|
|
290
|
+
if file_path:
|
|
291
|
+
meta_lines.append(f"[dim]File [/dim] {file_path}")
|
|
292
|
+
if lines_str:
|
|
293
|
+
meta_lines.append(f"[dim]Lines [/dim] {lines_str}")
|
|
294
|
+
if cols_str:
|
|
295
|
+
meta_lines.append(f"[dim]Cols [/dim] {cols_str}")
|
|
296
|
+
|
|
297
|
+
panel_content = Text.from_markup("\n".join(meta_lines))
|
|
298
|
+
console.print(Panel(panel_content, title=header, title_align="left"))
|
|
299
|
+
|
|
300
|
+
title = data.get("title", "")
|
|
301
|
+
description = data.get("description", "")
|
|
302
|
+
if title:
|
|
303
|
+
console.print(f"\n[bold]{title}[/bold]")
|
|
304
|
+
if description and description != title:
|
|
305
|
+
console.print(f"\n{description}")
|
|
306
|
+
|
|
307
|
+
code_snippet = data.get("codeSnippet")
|
|
308
|
+
if code_snippet:
|
|
309
|
+
console.print("\n[bold]Code[/bold]")
|
|
310
|
+
lang = _lang_from_path(file_path)
|
|
311
|
+
console.print(Syntax(code_snippet, lang, theme="monokai", line_numbers=False))
|
|
312
|
+
|
|
313
|
+
remediation = data.get("remediation", "")
|
|
314
|
+
if remediation:
|
|
315
|
+
console.print("\n[bold]Remediation[/bold]")
|
|
316
|
+
console.print(f" {remediation}")
|
|
317
|
+
|
|
318
|
+
metadata = data.get("metadata")
|
|
319
|
+
if metadata:
|
|
320
|
+
console.print("\n[bold]Metadata[/bold]")
|
|
321
|
+
console.print_json(json.dumps(metadata))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
_CLAUDE_SKILL_CONTENT = """\
|
|
325
|
+
---
|
|
326
|
+
description: Check the repository or a specific file for KodeLens code quality issues
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
Check for active KodeLens code quality issues.
|
|
330
|
+
|
|
331
|
+
1. Run `kodelens issues list $ARGUMENTS` (pass e.g. `--file src/auth.py` or leave empty for all issues)
|
|
332
|
+
2. For any high/critical issues run `kodelens issues detail --id=<id>` to get full context: file path, line range, description, and remediation steps
|
|
333
|
+
3. Summarise findings and suggest fixes where appropriate
|
|
334
|
+
|
|
335
|
+
To dismiss an issue, ask the user for a reason, then run:
|
|
336
|
+
`kodelens issues dismiss --id=<id> --message="<reason>"`
|
|
337
|
+
Confirm the dismissal when prompted (y/N).
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
_CLAUDE_MD_SECTION = """\
|
|
341
|
+
|
|
342
|
+
## Code quality (KodeLens)
|
|
343
|
+
|
|
344
|
+
Use `kodelens issues list` to check for active security and complexity issues (default: high+critical).
|
|
345
|
+
Use `kodelens issues list --file <path>` before modifying a file.
|
|
346
|
+
Use `kodelens issues detail --id=<id>` for full context on a specific issue (file, lines, remediation).
|
|
347
|
+
Use `kodelens issues dismiss --id=<id> --message="<reason>"` to dismiss a false positive — always ask the user to confirm the reason first.
|
|
348
|
+
Use `/kodelens [--file <path>]` as a slash command shortcut.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
_COPILOT_SECTION = """\
|
|
352
|
+
## Code quality (KodeLens)
|
|
353
|
+
|
|
354
|
+
This project uses KodeLens for code quality analysis.
|
|
355
|
+
|
|
356
|
+
- `kodelens issues list` — list active issues (default: high+critical severity)
|
|
357
|
+
- `kodelens issues list --file <path>` — check a specific file before editing it
|
|
358
|
+
- `kodelens issues detail --id=<id>` — full context: file, line range, and remediation
|
|
359
|
+
- `kodelens issues dismiss --id=<id> --message="<reason>"` — dismiss a false positive with a reason
|
|
360
|
+
|
|
361
|
+
Run `kodelens issues list --file <path>` before modifying any file to check for pre-existing issues.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _configure_claude_code() -> None:
|
|
366
|
+
commands_dir = Path(".claude") / "commands"
|
|
367
|
+
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
skill_file = commands_dir / "kodelens.md"
|
|
369
|
+
if skill_file.exists():
|
|
370
|
+
console.print(f"[dim]Already configured: {skill_file}[/dim]")
|
|
371
|
+
else:
|
|
372
|
+
skill_file.write_text(_CLAUDE_SKILL_CONTENT)
|
|
373
|
+
console.print(f"[green]Created[/green] {skill_file}")
|
|
374
|
+
|
|
375
|
+
claude_md = Path("CLAUDE.md")
|
|
376
|
+
existing = claude_md.read_text() if claude_md.exists() else ""
|
|
377
|
+
if "KodeLens" in existing:
|
|
378
|
+
console.print(f"[dim]Already configured: CLAUDE.md[/dim]")
|
|
379
|
+
else:
|
|
380
|
+
with claude_md.open("a") as f:
|
|
381
|
+
f.write(_CLAUDE_MD_SECTION)
|
|
382
|
+
action = "Updated" if existing else "Created"
|
|
383
|
+
console.print(f"[green]{action}[/green] CLAUDE.md")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _configure_github_copilot() -> None:
|
|
387
|
+
github_dir = Path(".github")
|
|
388
|
+
github_dir.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
instructions_file = github_dir / "copilot-instructions.md"
|
|
390
|
+
existing = instructions_file.read_text() if instructions_file.exists() else ""
|
|
391
|
+
if "KodeLens" in existing:
|
|
392
|
+
console.print(f"[dim]Already configured: {instructions_file}[/dim]")
|
|
393
|
+
else:
|
|
394
|
+
with instructions_file.open("a") as f:
|
|
395
|
+
if existing and not existing.endswith("\n"):
|
|
396
|
+
f.write("\n")
|
|
397
|
+
f.write(_COPILOT_SECTION)
|
|
398
|
+
action = "Updated" if existing else "Created"
|
|
399
|
+
console.print(f"[green]{action}[/green] {instructions_file}")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@cli.command()
|
|
403
|
+
@click.option(
|
|
404
|
+
"--claude-code", is_flag=True, help="Set up Claude Code skill and CLAUDE.md."
|
|
405
|
+
)
|
|
406
|
+
@click.option(
|
|
407
|
+
"--github-copilot", is_flag=True, help="Set up GitHub Copilot instructions."
|
|
408
|
+
)
|
|
409
|
+
def configure(claude_code, github_copilot):
|
|
410
|
+
"""Write AI tool integration files for the current project."""
|
|
411
|
+
if not claude_code and not github_copilot:
|
|
412
|
+
raise click.UsageError(
|
|
413
|
+
"Specify at least one tool: --claude-code or --github-copilot"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
if claude_code:
|
|
417
|
+
_configure_claude_code()
|
|
418
|
+
|
|
419
|
+
if github_copilot:
|
|
420
|
+
_configure_github_copilot()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from kodelens_cli.config import get_api_key, get_url
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KodeLensClient:
|
|
9
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None):
|
|
10
|
+
self._base_url = (base_url or get_url()).rstrip("/")
|
|
11
|
+
self._api_key = api_key or get_api_key()
|
|
12
|
+
|
|
13
|
+
def _headers(self) -> dict:
|
|
14
|
+
headers = {"Accept": "application/json"}
|
|
15
|
+
if self._api_key:
|
|
16
|
+
headers["X-Api-Key"] = self._api_key
|
|
17
|
+
return headers
|
|
18
|
+
|
|
19
|
+
def _get(self, path: str, params: dict | None = None) -> dict:
|
|
20
|
+
url = f"{self._base_url}{path}"
|
|
21
|
+
with httpx.Client(timeout=30) as client:
|
|
22
|
+
resp = client.get(url, params=params, headers=self._headers())
|
|
23
|
+
self._raise(resp)
|
|
24
|
+
return resp.json()
|
|
25
|
+
|
|
26
|
+
def _post(self, path: str, data: dict) -> dict:
|
|
27
|
+
url = f"{self._base_url}{path}"
|
|
28
|
+
with httpx.Client(timeout=30) as client:
|
|
29
|
+
resp = client.post(url, json=data, headers=self._headers())
|
|
30
|
+
self._raise(resp)
|
|
31
|
+
return resp.json()
|
|
32
|
+
|
|
33
|
+
def _patch(self, path: str, data: dict) -> dict:
|
|
34
|
+
url = f"{self._base_url}{path}"
|
|
35
|
+
with httpx.Client(timeout=30) as client:
|
|
36
|
+
resp = client.patch(url, json=data, headers=self._headers())
|
|
37
|
+
self._raise(resp)
|
|
38
|
+
return resp.json()
|
|
39
|
+
|
|
40
|
+
def _raise(self, resp: httpx.Response) -> None:
|
|
41
|
+
if resp.status_code == 401:
|
|
42
|
+
raise AuthError(
|
|
43
|
+
"Not authenticated. Run `kodelens login` or set KODELENS_API_KEY."
|
|
44
|
+
)
|
|
45
|
+
if resp.status_code == 404:
|
|
46
|
+
raise NotFoundError("Resource not found.")
|
|
47
|
+
if not resp.is_success:
|
|
48
|
+
try:
|
|
49
|
+
detail = resp.json().get("error") or resp.text
|
|
50
|
+
except Exception:
|
|
51
|
+
detail = resp.text
|
|
52
|
+
raise ApiError(f"API error {resp.status_code}: {detail}")
|
|
53
|
+
|
|
54
|
+
def find_repository(
|
|
55
|
+
self, *, remote_url: str | None = None, repo_id: int | None = None
|
|
56
|
+
) -> dict:
|
|
57
|
+
params = {}
|
|
58
|
+
if repo_id:
|
|
59
|
+
params["repo_id"] = repo_id
|
|
60
|
+
elif remote_url:
|
|
61
|
+
params["remote_url"] = remote_url
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError("Provide remote_url or repo_id.")
|
|
64
|
+
return self._get("/api/cli/repository/", params=params)
|
|
65
|
+
|
|
66
|
+
def repository_summary(self, repo_id: int) -> dict:
|
|
67
|
+
return self._get(f"/api/cli/repositories/{repo_id}/summary/")
|
|
68
|
+
|
|
69
|
+
def repository_observations(
|
|
70
|
+
self,
|
|
71
|
+
repo_id: int,
|
|
72
|
+
*,
|
|
73
|
+
severity: str | None = None,
|
|
74
|
+
obs_type: str | None = None,
|
|
75
|
+
file_path: str | None = None,
|
|
76
|
+
page: int = 1,
|
|
77
|
+
per_page: int = 50,
|
|
78
|
+
) -> dict:
|
|
79
|
+
params: dict = {"page": page, "per_page": per_page}
|
|
80
|
+
if severity:
|
|
81
|
+
params["severity"] = severity
|
|
82
|
+
if obs_type:
|
|
83
|
+
params["type"] = obs_type
|
|
84
|
+
if file_path:
|
|
85
|
+
params["file_path"] = file_path
|
|
86
|
+
return self._get(
|
|
87
|
+
f"/api/cli/repositories/{repo_id}/observations/", params=params
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def create_branch(self, repo_id: int, branch_name: str, from_branch: str) -> dict:
|
|
91
|
+
return self._post(
|
|
92
|
+
f"/api/cli/repositories/{repo_id}/branches/",
|
|
93
|
+
{"branch_name": branch_name, "from_branch": from_branch},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def observation_detail(self, observation_id: int) -> dict:
|
|
97
|
+
return self._get(f"/api/cli/observations/{observation_id}/")
|
|
98
|
+
|
|
99
|
+
def dismiss_observation(self, observation_id: int, reason: str) -> dict:
|
|
100
|
+
return self._patch(
|
|
101
|
+
f"/api/cli/observations/{observation_id}/",
|
|
102
|
+
{"is_dismissed": True, "dismissal_reason": reason},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AuthError(Exception):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class NotFoundError(Exception):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ApiError(Exception):
|
|
115
|
+
pass
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config resolution — priority:
|
|
3
|
+
1. KODELENS_REPO_ID env var
|
|
4
|
+
2. Auto-detect from .git/config remote URL → /api/cli/repository/?remote_url=
|
|
5
|
+
3. KODELENS_API_KEY env var
|
|
6
|
+
4. ~/.config/kodelens/credentials.json (from `kodelens login`)
|
|
7
|
+
5. KODELENS_URL env var (default: https://app.kodelens.io)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
CREDENTIALS_FILE = Path.home() / ".config" / "kodelens" / "credentials.json"
|
|
18
|
+
DEFAULT_URL = "https://kodelens.kodestack.io"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_url() -> str:
|
|
22
|
+
return os.environ.get("KODELENS_URL", DEFAULT_URL).rstrip("/")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_api_key() -> str | None:
|
|
26
|
+
if key := os.environ.get("KODELENS_API_KEY"):
|
|
27
|
+
return key
|
|
28
|
+
if CREDENTIALS_FILE.exists():
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(CREDENTIALS_FILE.read_text())
|
|
31
|
+
return data.get("api_key")
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_credentials(api_key: str) -> None:
|
|
38
|
+
CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
CREDENTIALS_FILE.write_text(json.dumps({"api_key": api_key}))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clear_credentials() -> None:
|
|
43
|
+
if CREDENTIALS_FILE.exists():
|
|
44
|
+
CREDENTIALS_FILE.unlink()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_repo_id_override() -> int | None:
|
|
48
|
+
value = os.environ.get("KODELENS_REPO_ID")
|
|
49
|
+
return int(value) if value else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_remote_url() -> str | None:
|
|
53
|
+
"""Use git to find the origin remote URL of the current repository."""
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["git", "remote", "get-url", "origin"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=5,
|
|
60
|
+
)
|
|
61
|
+
if result.returncode == 0:
|
|
62
|
+
return result.stdout.strip()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
return None
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 2
|
|
3
|
+
requires-python = ">=3.11"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "anyio"
|
|
7
|
+
version = "4.13.0"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
dependencies = [
|
|
10
|
+
{ name = "idna" },
|
|
11
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
12
|
+
]
|
|
13
|
+
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
|
14
|
+
wheels = [
|
|
15
|
+
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[[package]]
|
|
19
|
+
name = "certifi"
|
|
20
|
+
version = "2026.4.22"
|
|
21
|
+
source = { registry = "https://pypi.org/simple" }
|
|
22
|
+
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
|
23
|
+
wheels = [
|
|
24
|
+
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[[package]]
|
|
28
|
+
name = "click"
|
|
29
|
+
version = "8.3.3"
|
|
30
|
+
source = { registry = "https://pypi.org/simple" }
|
|
31
|
+
dependencies = [
|
|
32
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
33
|
+
]
|
|
34
|
+
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
|
35
|
+
wheels = [
|
|
36
|
+
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[[package]]
|
|
40
|
+
name = "colorama"
|
|
41
|
+
version = "0.4.6"
|
|
42
|
+
source = { registry = "https://pypi.org/simple" }
|
|
43
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
44
|
+
wheels = [
|
|
45
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[[package]]
|
|
49
|
+
name = "h11"
|
|
50
|
+
version = "0.16.0"
|
|
51
|
+
source = { registry = "https://pypi.org/simple" }
|
|
52
|
+
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
53
|
+
wheels = [
|
|
54
|
+
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[[package]]
|
|
58
|
+
name = "httpcore"
|
|
59
|
+
version = "1.0.9"
|
|
60
|
+
source = { registry = "https://pypi.org/simple" }
|
|
61
|
+
dependencies = [
|
|
62
|
+
{ name = "certifi" },
|
|
63
|
+
{ name = "h11" },
|
|
64
|
+
]
|
|
65
|
+
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
66
|
+
wheels = [
|
|
67
|
+
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[[package]]
|
|
71
|
+
name = "httpx"
|
|
72
|
+
version = "0.28.1"
|
|
73
|
+
source = { registry = "https://pypi.org/simple" }
|
|
74
|
+
dependencies = [
|
|
75
|
+
{ name = "anyio" },
|
|
76
|
+
{ name = "certifi" },
|
|
77
|
+
{ name = "httpcore" },
|
|
78
|
+
{ name = "idna" },
|
|
79
|
+
]
|
|
80
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
81
|
+
wheels = [
|
|
82
|
+
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[[package]]
|
|
86
|
+
name = "idna"
|
|
87
|
+
version = "3.13"
|
|
88
|
+
source = { registry = "https://pypi.org/simple" }
|
|
89
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
|
90
|
+
wheels = [
|
|
91
|
+
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[[package]]
|
|
95
|
+
name = "kodelens"
|
|
96
|
+
version = "0.1.0"
|
|
97
|
+
source = { editable = "." }
|
|
98
|
+
dependencies = [
|
|
99
|
+
{ name = "click" },
|
|
100
|
+
{ name = "httpx" },
|
|
101
|
+
{ name = "rich" },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
[package.metadata]
|
|
105
|
+
requires-dist = [
|
|
106
|
+
{ name = "click", specifier = ">=8.1" },
|
|
107
|
+
{ name = "httpx", specifier = ">=0.27" },
|
|
108
|
+
{ name = "rich", specifier = ">=13.0" },
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
[[package]]
|
|
112
|
+
name = "markdown-it-py"
|
|
113
|
+
version = "4.0.0"
|
|
114
|
+
source = { registry = "https://pypi.org/simple" }
|
|
115
|
+
dependencies = [
|
|
116
|
+
{ name = "mdurl" },
|
|
117
|
+
]
|
|
118
|
+
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
|
119
|
+
wheels = [
|
|
120
|
+
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
[[package]]
|
|
124
|
+
name = "mdurl"
|
|
125
|
+
version = "0.1.2"
|
|
126
|
+
source = { registry = "https://pypi.org/simple" }
|
|
127
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
|
128
|
+
wheels = [
|
|
129
|
+
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
[[package]]
|
|
133
|
+
name = "pygments"
|
|
134
|
+
version = "2.20.0"
|
|
135
|
+
source = { registry = "https://pypi.org/simple" }
|
|
136
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
|
137
|
+
wheels = [
|
|
138
|
+
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
[[package]]
|
|
142
|
+
name = "rich"
|
|
143
|
+
version = "15.0.0"
|
|
144
|
+
source = { registry = "https://pypi.org/simple" }
|
|
145
|
+
dependencies = [
|
|
146
|
+
{ name = "markdown-it-py" },
|
|
147
|
+
{ name = "pygments" },
|
|
148
|
+
]
|
|
149
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
|
150
|
+
wheels = [
|
|
151
|
+
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
[[package]]
|
|
155
|
+
name = "typing-extensions"
|
|
156
|
+
version = "4.15.0"
|
|
157
|
+
source = { registry = "https://pypi.org/simple" }
|
|
158
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
159
|
+
wheels = [
|
|
160
|
+
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
161
|
+
]
|