kodelens-cli 0.1.1__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,9 @@
1
+ from kodelens_cli.cli import cli
2
+
3
+
4
+ def main():
5
+ cli()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
kodelens_cli/auth.py ADDED
@@ -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&rsquo;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()
kodelens_cli/cli.py ADDED
@@ -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()
kodelens_cli/client.py ADDED
@@ -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
kodelens_cli/config.py ADDED
@@ -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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: kodelens-cli
3
+ Version: 0.1.1
4
+ Summary: CLI for KodeLens — surface code health issues in your terminal and AI coding tools
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: rich>=13.0
@@ -0,0 +1,10 @@
1
+ kodelens_cli/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
2
+ kodelens_cli/__main__.py,sha256=vfAFsPr8ERKdMkyIs8Ae4lbG_XE0AeZCowyQNo-aJWk,97
3
+ kodelens_cli/auth.py,sha256=YBH7dKojr0Mgd4_9ddn909KlPNjF-roth5bJkEFO6vY,4290
4
+ kodelens_cli/cli.py,sha256=glB0zLOeljhz1Y60KVGXYGxnxryyE1vK6Jx5TWjHzn4,13708
5
+ kodelens_cli/client.py,sha256=PeezRKy1-_E6WdBF5ygTbdd5pUbSbIxiu9rN6Ev0x18,3768
6
+ kodelens_cli/config.py,sha256=QtlXBkss-pyG6Yx8-OvYml8s5EMw3KwusVlHjFakUUI,1760
7
+ kodelens_cli-0.1.1.dist-info/METADATA,sha256=XTqRnMDXatqJTEzlSAvh5FosD5i875X5rWmj39cn95c,253
8
+ kodelens_cli-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ kodelens_cli-0.1.1.dist-info/entry_points.txt,sha256=nRdXk7QxlmRSCtbouGcb0zeutYVtrFlFCRQln3-_ZUc,56
10
+ kodelens_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kodelens = kodelens_cli.__main__:main