codedd-cli 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.
- codedd_cli/__init__.py +3 -0
- codedd_cli/__main__.py +19 -0
- codedd_cli/api/__init__.py +4 -0
- codedd_cli/api/client.py +118 -0
- codedd_cli/api/endpoints.py +44 -0
- codedd_cli/api/exceptions.py +25 -0
- codedd_cli/auditor/__init__.py +6 -0
- codedd_cli/auditor/architecture_analyzer.py +1241 -0
- codedd_cli/auditor/architecture_prompts.py +171 -0
- codedd_cli/auditor/complexity_analyzer.py +942 -0
- codedd_cli/auditor/dependency_scanner.py +2478 -0
- codedd_cli/auditor/file_auditor.py +572 -0
- codedd_cli/auditor/git_stats_collector.py +332 -0
- codedd_cli/auditor/response_parser.py +487 -0
- codedd_cli/auditor/vulnerability_validator.py +324 -0
- codedd_cli/auth/__init__.py +4 -0
- codedd_cli/auth/session.py +41 -0
- codedd_cli/auth/token_manager.py +87 -0
- codedd_cli/cli.py +69 -0
- codedd_cli/commands/__init__.py +1 -0
- codedd_cli/commands/audit_cmd.py +1877 -0
- codedd_cli/commands/audits_cmd.py +273 -0
- codedd_cli/commands/auth_cmd.py +230 -0
- codedd_cli/commands/config_cmd.py +454 -0
- codedd_cli/commands/scope_cmd.py +967 -0
- codedd_cli/config/__init__.py +4 -0
- codedd_cli/config/constants.py +22 -0
- codedd_cli/config/settings.py +369 -0
- codedd_cli/llm/__init__.py +1 -0
- codedd_cli/llm/key_manager.py +271 -0
- codedd_cli/models/__init__.py +5 -0
- codedd_cli/models/account.py +13 -0
- codedd_cli/models/audit.py +32 -0
- codedd_cli/models/local_directory.py +26 -0
- codedd_cli/scanner/__init__.py +18 -0
- codedd_cli/scanner/file_classifier.py +247 -0
- codedd_cli/scanner/file_walker.py +207 -0
- codedd_cli/scanner/line_counter.py +75 -0
- codedd_cli/utils/__init__.py +1 -0
- codedd_cli/utils/directory_validator.py +179 -0
- codedd_cli/utils/display.py +493 -0
- codedd_cli/utils/payload_inspector.py +180 -0
- codedd_cli/utils/security.py +14 -0
- codedd_cli/utils/validators.py +37 -0
- codedd_cli-0.1.0.dist-info/METADATA +276 -0
- codedd_cli-0.1.0.dist-info/RECORD +49 -0
- codedd_cli-0.1.0.dist-info/WHEEL +4 -0
- codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application-wide constants for the CodeDD CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# Application identity
|
|
6
|
+
APP_NAME = "codedd-cli"
|
|
7
|
+
CONFIG_DIR_NAME = ".codedd"
|
|
8
|
+
|
|
9
|
+
# Default API endpoint (production)
|
|
10
|
+
DEFAULT_API_URL = "https://api.codedd.ai/django_codedd"
|
|
11
|
+
|
|
12
|
+
# Keyring service name used to store the CLI token in the OS credential store
|
|
13
|
+
KEYRING_SERVICE = "codedd-cli"
|
|
14
|
+
KEYRING_TOKEN_KEY = "cli_token"
|
|
15
|
+
|
|
16
|
+
# CLI token prefix expected by the server
|
|
17
|
+
CLI_TOKEN_PREFIX = "codedd_cli_"
|
|
18
|
+
|
|
19
|
+
# HTTP client settings
|
|
20
|
+
REQUEST_TIMEOUT_SECONDS = 30
|
|
21
|
+
MAX_RETRIES = 3
|
|
22
|
+
USER_AGENT_PREFIX = "codedd-cli"
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration file management for the CodeDD CLI.
|
|
3
|
+
|
|
4
|
+
Reads and writes ``~/.codedd/config.toml`` using the TOML format.
|
|
5
|
+
The config file stores non-sensitive session metadata; the actual
|
|
6
|
+
CLI token is kept in the OS keychain via the ``keyring`` library.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import tomli_w
|
|
16
|
+
|
|
17
|
+
if sys.version_info >= (3, 11):
|
|
18
|
+
import tomllib
|
|
19
|
+
else:
|
|
20
|
+
import tomli as tomllib
|
|
21
|
+
|
|
22
|
+
from codedd_cli.config.constants import CONFIG_DIR_NAME, DEFAULT_API_URL
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _config_dir() -> Path:
|
|
26
|
+
"""Return the path to ``~/.codedd/``, creating it if necessary."""
|
|
27
|
+
path = Path.home() / CONFIG_DIR_NAME
|
|
28
|
+
if not path.exists():
|
|
29
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
# Restrict directory permissions to owner only (Unix)
|
|
31
|
+
try:
|
|
32
|
+
path.chmod(stat.S_IRWXU)
|
|
33
|
+
except OSError:
|
|
34
|
+
pass # Windows does not support Unix-style permissions
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _config_file() -> Path:
|
|
39
|
+
"""Return the path to ``~/.codedd/config.toml``."""
|
|
40
|
+
return _config_dir() / "config.toml"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConfigManager:
|
|
44
|
+
"""
|
|
45
|
+
Read/write helper for the TOML configuration file.
|
|
46
|
+
|
|
47
|
+
Sections:
|
|
48
|
+
[server] – ``api_url``
|
|
49
|
+
[session] – ``account_uuid``, ``account_name``, ``token_hint``
|
|
50
|
+
[active_audit] – ``audit_uuid``, ``audit_type``, ``audit_name``
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self._path = _config_file()
|
|
55
|
+
self._data: dict[str, Any] = self._load()
|
|
56
|
+
|
|
57
|
+
# ---- persistence ----
|
|
58
|
+
|
|
59
|
+
def _load(self) -> dict[str, Any]:
|
|
60
|
+
"""Load config from disk; return defaults if file is missing."""
|
|
61
|
+
if not self._path.exists():
|
|
62
|
+
return self._defaults()
|
|
63
|
+
with open(self._path, "rb") as fh:
|
|
64
|
+
data = tomllib.load(fh)
|
|
65
|
+
# Migrate legacy scope_directories (single list) to per-audit: add audit_uuid to each entry
|
|
66
|
+
if "scope_directories" in data and isinstance(data["scope_directories"], list):
|
|
67
|
+
active = data.get("active_audit", {}).get("audit_uuid", "")
|
|
68
|
+
for entry in data["scope_directories"]:
|
|
69
|
+
if isinstance(entry, dict) and "audit_uuid" not in entry and active:
|
|
70
|
+
entry["audit_uuid"] = active
|
|
71
|
+
data["scope_entries"] = data.pop("scope_directories", [])
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
def save(self) -> None:
|
|
75
|
+
"""Write current config state to disk with restricted permissions."""
|
|
76
|
+
with open(self._path, "wb") as fh:
|
|
77
|
+
tomli_w.dump(self._data, fh)
|
|
78
|
+
# Restrict file permissions to owner only
|
|
79
|
+
try:
|
|
80
|
+
self._path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _defaults() -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
"server": {"api_url": DEFAULT_API_URL},
|
|
88
|
+
"session": {"account_uuid": "", "account_name": "", "token_hint": ""},
|
|
89
|
+
"active_audit": {"audit_uuid": "", "audit_type": "", "audit_name": ""},
|
|
90
|
+
"scope_entries": [], # per-audit: list of { "audit_uuid", "path", "repo_name", "branch", "commit_hash", "confirmed", "needs_reconfirm", "last_sync" }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# ---- server section ----
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def api_url(self) -> str:
|
|
97
|
+
return self._data.get("server", {}).get("api_url", DEFAULT_API_URL)
|
|
98
|
+
|
|
99
|
+
@api_url.setter
|
|
100
|
+
def api_url(self, value: str) -> None:
|
|
101
|
+
self._data.setdefault("server", {})["api_url"] = value
|
|
102
|
+
|
|
103
|
+
# ---- session section ----
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def account_uuid(self) -> str:
|
|
107
|
+
return self._data.get("session", {}).get("account_uuid", "")
|
|
108
|
+
|
|
109
|
+
@account_uuid.setter
|
|
110
|
+
def account_uuid(self, value: str) -> None:
|
|
111
|
+
self._data.setdefault("session", {})["account_uuid"] = value
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def account_name(self) -> str:
|
|
115
|
+
return self._data.get("session", {}).get("account_name", "")
|
|
116
|
+
|
|
117
|
+
@account_name.setter
|
|
118
|
+
def account_name(self, value: str) -> None:
|
|
119
|
+
self._data.setdefault("session", {})["account_name"] = value
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def token_hint(self) -> str:
|
|
123
|
+
return self._data.get("session", {}).get("token_hint", "")
|
|
124
|
+
|
|
125
|
+
@token_hint.setter
|
|
126
|
+
def token_hint(self, value: str) -> None:
|
|
127
|
+
self._data.setdefault("session", {})["token_hint"] = value
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_authenticated(self) -> bool:
|
|
131
|
+
"""Return True when a session is stored (token_hint is non-empty)."""
|
|
132
|
+
return bool(self.token_hint)
|
|
133
|
+
|
|
134
|
+
# ---- active_audit section ----
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def active_audit_uuid(self) -> str:
|
|
138
|
+
return self._data.get("active_audit", {}).get("audit_uuid", "")
|
|
139
|
+
|
|
140
|
+
@active_audit_uuid.setter
|
|
141
|
+
def active_audit_uuid(self, value: str) -> None:
|
|
142
|
+
self._data.setdefault("active_audit", {})["audit_uuid"] = value
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def active_audit_type(self) -> str:
|
|
146
|
+
return self._data.get("active_audit", {}).get("audit_type", "")
|
|
147
|
+
|
|
148
|
+
@active_audit_type.setter
|
|
149
|
+
def active_audit_type(self, value: str) -> None:
|
|
150
|
+
self._data.setdefault("active_audit", {})["audit_type"] = value
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def active_audit_name(self) -> str:
|
|
154
|
+
return self._data.get("active_audit", {}).get("audit_name", "")
|
|
155
|
+
|
|
156
|
+
@active_audit_name.setter
|
|
157
|
+
def active_audit_name(self, value: str) -> None:
|
|
158
|
+
self._data.setdefault("active_audit", {})["audit_name"] = value
|
|
159
|
+
|
|
160
|
+
# ---- llm section ----
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def llm_provider(self) -> str:
|
|
164
|
+
"""
|
|
165
|
+
Return the preferred LLM provider: ``"anthropic"``, ``"openai"``,
|
|
166
|
+
or ``"both"`` (default).
|
|
167
|
+
|
|
168
|
+
When ``"both"`` is selected the pipeline uses Anthropic as primary
|
|
169
|
+
with OpenAI as fallback, mirroring the server-side behaviour.
|
|
170
|
+
"""
|
|
171
|
+
return self._data.get("llm", {}).get("provider", "both")
|
|
172
|
+
|
|
173
|
+
@llm_provider.setter
|
|
174
|
+
def llm_provider(self, value: str) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Set the LLM provider preference.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
value: One of ``"anthropic"``, ``"openai"``, ``"both"``.
|
|
180
|
+
"""
|
|
181
|
+
self._data.setdefault("llm", {})["provider"] = value
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def llm_concurrency(self) -> int:
|
|
185
|
+
"""
|
|
186
|
+
Maximum number of concurrent LLM API calls during file auditing.
|
|
187
|
+
|
|
188
|
+
Depends on your API tier / rate-limit contract with the LLM provider.
|
|
189
|
+
Higher values speed up audits but may trigger rate-limit errors.
|
|
190
|
+
Default is ``4``; server plan may override this.
|
|
191
|
+
|
|
192
|
+
Stored under ``[llm].concurrency`` in ``config.toml``.
|
|
193
|
+
"""
|
|
194
|
+
return int(self._data.get("llm", {}).get("concurrency", 4))
|
|
195
|
+
|
|
196
|
+
@llm_concurrency.setter
|
|
197
|
+
def llm_concurrency(self, value: int) -> None:
|
|
198
|
+
"""Set the LLM concurrency limit (1–32)."""
|
|
199
|
+
clamped = max(1, min(32, int(value)))
|
|
200
|
+
self._data.setdefault("llm", {})["concurrency"] = clamped
|
|
201
|
+
|
|
202
|
+
# ---- convenience ----
|
|
203
|
+
|
|
204
|
+
def get(self, section: str, key: str, default: Any = "") -> Any:
|
|
205
|
+
"""Generic getter."""
|
|
206
|
+
return self._data.get(section, {}).get(key, default)
|
|
207
|
+
|
|
208
|
+
def set(self, section: str, key: str, value: Any) -> None:
|
|
209
|
+
"""Generic setter (auto-saves)."""
|
|
210
|
+
self._data.setdefault(section, {})[key] = value
|
|
211
|
+
self.save()
|
|
212
|
+
|
|
213
|
+
def clear_session(self) -> None:
|
|
214
|
+
"""Wipe all session, active audit, and scope data (used on logout)."""
|
|
215
|
+
self._data["session"] = {"account_uuid": "", "account_name": "", "token_hint": ""}
|
|
216
|
+
self._data["active_audit"] = {"audit_uuid": "", "audit_type": "", "audit_name": ""}
|
|
217
|
+
self._data["scope_entries"] = []
|
|
218
|
+
self.save()
|
|
219
|
+
|
|
220
|
+
def set_active_audit(self, audit_uuid: str, audit_type: str, audit_name: str) -> None:
|
|
221
|
+
"""Set the currently selected audit."""
|
|
222
|
+
self.active_audit_uuid = audit_uuid
|
|
223
|
+
self.active_audit_type = audit_type
|
|
224
|
+
self.active_audit_name = audit_name
|
|
225
|
+
self.save()
|
|
226
|
+
|
|
227
|
+
# ---- scope (per-audit) section ----
|
|
228
|
+
|
|
229
|
+
def _scope_entries_for_active_audit(self) -> list[dict[str, Any]]:
|
|
230
|
+
"""Return scope entries that belong to the currently active audit."""
|
|
231
|
+
active = self.active_audit_uuid
|
|
232
|
+
if not active:
|
|
233
|
+
return []
|
|
234
|
+
all_entries = self._data.get("scope_entries", [])
|
|
235
|
+
return [e for e in all_entries if isinstance(e, dict) and e.get("audit_uuid") == active]
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def scope_directories(self) -> list[dict[str, str]]:
|
|
239
|
+
"""
|
|
240
|
+
Return the list of directories in the **current audit's** scope.
|
|
241
|
+
|
|
242
|
+
Each entry is a dict with keys: ``path``, ``repo_name``, ``branch``,
|
|
243
|
+
``commit_hash``, ``confirmed``, ``needs_reconfirm``, ``last_sync``.
|
|
244
|
+
Scope is stored per audit so the same path can be used in different audits.
|
|
245
|
+
"""
|
|
246
|
+
entries = self._scope_entries_for_active_audit()
|
|
247
|
+
return [
|
|
248
|
+
{
|
|
249
|
+
"path": e["path"],
|
|
250
|
+
"repo_name": e["repo_name"],
|
|
251
|
+
"branch": e["branch"],
|
|
252
|
+
"commit_hash": e["commit_hash"],
|
|
253
|
+
"confirmed": e.get("confirmed", False),
|
|
254
|
+
"needs_reconfirm": e.get("needs_reconfirm", False),
|
|
255
|
+
"last_sync": e.get("last_sync", ""),
|
|
256
|
+
}
|
|
257
|
+
for e in entries
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
def add_scope_directory(self, path: str, repo_name: str, branch: str, commit_hash: str) -> bool:
|
|
261
|
+
"""
|
|
262
|
+
Add a validated directory to the **current audit's** scope.
|
|
263
|
+
|
|
264
|
+
Returns False if the path is already present for this audit (duplicate).
|
|
265
|
+
The same path can be in scope for a different audit.
|
|
266
|
+
"""
|
|
267
|
+
active = self.active_audit_uuid
|
|
268
|
+
if not active:
|
|
269
|
+
return False
|
|
270
|
+
entries = self._data.setdefault("scope_entries", [])
|
|
271
|
+
normalised = str(Path(path).resolve())
|
|
272
|
+
for entry in entries:
|
|
273
|
+
if isinstance(entry, dict) and entry.get("audit_uuid") == active:
|
|
274
|
+
if str(Path(entry.get("path", "")).resolve()) == normalised:
|
|
275
|
+
return False # duplicate for this audit
|
|
276
|
+
entries.append({
|
|
277
|
+
"audit_uuid": active,
|
|
278
|
+
"path": normalised,
|
|
279
|
+
"repo_name": repo_name,
|
|
280
|
+
"branch": branch,
|
|
281
|
+
"commit_hash": commit_hash,
|
|
282
|
+
"confirmed": False,
|
|
283
|
+
"needs_reconfirm": False,
|
|
284
|
+
"last_sync": "",
|
|
285
|
+
})
|
|
286
|
+
self.save()
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
def remove_scope_directory(self, index: int) -> bool:
|
|
290
|
+
"""
|
|
291
|
+
Remove a directory from the **current audit's** scope by 0-based index.
|
|
292
|
+
|
|
293
|
+
Returns False if the index is out of range.
|
|
294
|
+
"""
|
|
295
|
+
current = self._scope_entries_for_active_audit()
|
|
296
|
+
if index < 0 or index >= len(current):
|
|
297
|
+
return False
|
|
298
|
+
to_remove = current[index]
|
|
299
|
+
all_entries = self._data.get("scope_entries", [])
|
|
300
|
+
# Remove the entry that matches this audit and is at this position in the filtered list
|
|
301
|
+
path_to_remove = to_remove.get("path")
|
|
302
|
+
audit_uuid = self.active_audit_uuid
|
|
303
|
+
removed = False
|
|
304
|
+
for i, e in enumerate(all_entries):
|
|
305
|
+
if isinstance(e, dict) and e.get("audit_uuid") == audit_uuid and e.get("path") == path_to_remove:
|
|
306
|
+
all_entries.pop(i)
|
|
307
|
+
removed = True
|
|
308
|
+
break
|
|
309
|
+
if removed:
|
|
310
|
+
self.save()
|
|
311
|
+
return removed
|
|
312
|
+
|
|
313
|
+
def clear_scope_directories(self) -> None:
|
|
314
|
+
"""Remove all directories from the **current audit's** scope."""
|
|
315
|
+
active = self.active_audit_uuid
|
|
316
|
+
if not active:
|
|
317
|
+
return
|
|
318
|
+
all_entries = self._data.get("scope_entries", [])
|
|
319
|
+
self._data["scope_entries"] = [e for e in all_entries if not (isinstance(e, dict) and e.get("audit_uuid") == active)]
|
|
320
|
+
self.save()
|
|
321
|
+
|
|
322
|
+
# ---- sync state helpers ----
|
|
323
|
+
|
|
324
|
+
def mark_scope_confirmed(self) -> None:
|
|
325
|
+
"""
|
|
326
|
+
Set ``confirmed=True`` and ``needs_reconfirm=False`` on **all**
|
|
327
|
+
scope entries for the active audit (called after ``scope confirm``).
|
|
328
|
+
"""
|
|
329
|
+
active = self.active_audit_uuid
|
|
330
|
+
if not active:
|
|
331
|
+
return
|
|
332
|
+
for entry in self._data.get("scope_entries", []):
|
|
333
|
+
if isinstance(entry, dict) and entry.get("audit_uuid") == active:
|
|
334
|
+
entry["confirmed"] = True
|
|
335
|
+
entry["needs_reconfirm"] = False
|
|
336
|
+
self.save()
|
|
337
|
+
|
|
338
|
+
def mark_scope_needs_reconfirm(self, last_sync_iso: str = "") -> None:
|
|
339
|
+
"""
|
|
340
|
+
Flag that the active audit's scope has local changes and must be
|
|
341
|
+
re-confirmed before the audit can be started.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
last_sync_iso: ISO-8601 timestamp of when the sync was performed.
|
|
345
|
+
"""
|
|
346
|
+
active = self.active_audit_uuid
|
|
347
|
+
if not active:
|
|
348
|
+
return
|
|
349
|
+
for entry in self._data.get("scope_entries", []):
|
|
350
|
+
if isinstance(entry, dict) and entry.get("audit_uuid") == active:
|
|
351
|
+
entry["needs_reconfirm"] = True
|
|
352
|
+
if last_sync_iso:
|
|
353
|
+
entry["last_sync"] = last_sync_iso
|
|
354
|
+
self.save()
|
|
355
|
+
|
|
356
|
+
def update_scope_last_sync(self, last_sync_iso: str) -> None:
|
|
357
|
+
"""Update the ``last_sync`` timestamp for all active-audit scope entries."""
|
|
358
|
+
active = self.active_audit_uuid
|
|
359
|
+
if not active:
|
|
360
|
+
return
|
|
361
|
+
for entry in self._data.get("scope_entries", []):
|
|
362
|
+
if isinstance(entry, dict) and entry.get("audit_uuid") == active:
|
|
363
|
+
entry["last_sync"] = last_sync_iso
|
|
364
|
+
self.save()
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def config_path(self) -> Path:
|
|
368
|
+
"""Return the path to the config file (for display purposes)."""
|
|
369
|
+
return self._path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LLM API key management for the CodeDD CLI."""
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secure LLM API key management for the CodeDD CLI.
|
|
3
|
+
|
|
4
|
+
Keys are stored in the operating system's credential store via the
|
|
5
|
+
``keyring`` library — the same mechanism used for the CLI auth token.
|
|
6
|
+
No keys are ever written to plain-text configuration files.
|
|
7
|
+
|
|
8
|
+
Supported providers:
|
|
9
|
+
- ``anthropic`` (primary, claude-sonnet-4-6 / claude-haiku-4-5)
|
|
10
|
+
- ``openai`` (fallback, gpt-5.2)
|
|
11
|
+
|
|
12
|
+
Provider preference (``anthropic``, ``openai``, or ``both``) is stored in
|
|
13
|
+
``~/.codedd/config.toml`` under the ``[llm]`` section.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
import keyring
|
|
23
|
+
|
|
24
|
+
from codedd_cli.config.constants import KEYRING_SERVICE
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Keyring key names (same service as the CLI auth token)
|
|
29
|
+
_KEYRING_KEY_ANTHROPIC = "anthropic_api_key"
|
|
30
|
+
_KEYRING_KEY_OPENAI = "openai_api_key"
|
|
31
|
+
|
|
32
|
+
# Map of provider name → keyring key
|
|
33
|
+
_PROVIDER_KEYRING_MAP: dict[str, str] = {
|
|
34
|
+
"anthropic": _KEYRING_KEY_ANTHROPIC,
|
|
35
|
+
"openai": _KEYRING_KEY_OPENAI,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Valid provider preference values for the [llm] section in config.toml
|
|
39
|
+
VALID_PROVIDERS = ("anthropic", "openai", "both")
|
|
40
|
+
|
|
41
|
+
# Models used by the CodeDD audit pipeline (mirrors server-side defaults)
|
|
42
|
+
PROVIDER_MODELS: dict[str, str] = {
|
|
43
|
+
"anthropic": "claude-sonnet-4-6",
|
|
44
|
+
"openai": "gpt-5.2",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Lightweight validation endpoints
|
|
48
|
+
_ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages"
|
|
49
|
+
_OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LLMKeyManager:
|
|
53
|
+
"""
|
|
54
|
+
Manage LLM API keys in the OS keychain.
|
|
55
|
+
|
|
56
|
+
Usage::
|
|
57
|
+
|
|
58
|
+
mgr = LLMKeyManager()
|
|
59
|
+
mgr.store_key("anthropic", "sk-ant-...")
|
|
60
|
+
key = mgr.retrieve_key("anthropic")
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Storage
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def store_key(provider: str, api_key: str) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Persist an LLM API key in the OS keychain.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
provider: ``"anthropic"`` or ``"openai"``.
|
|
74
|
+
api_key: The raw API key string.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If the provider name is not recognised.
|
|
78
|
+
keyring.errors.KeyringError: If the keychain is unavailable.
|
|
79
|
+
"""
|
|
80
|
+
keyring_key = _PROVIDER_KEYRING_MAP.get(provider)
|
|
81
|
+
if not keyring_key:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Unknown provider '{provider}'. "
|
|
84
|
+
f"Supported: {', '.join(_PROVIDER_KEYRING_MAP)}"
|
|
85
|
+
)
|
|
86
|
+
keyring.set_password(KEYRING_SERVICE, keyring_key, api_key)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def retrieve_key(provider: str) -> Optional[str]:
|
|
90
|
+
"""
|
|
91
|
+
Retrieve an LLM API key from the OS keychain.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The key string, or ``None`` if no key is stored.
|
|
95
|
+
"""
|
|
96
|
+
keyring_key = _PROVIDER_KEYRING_MAP.get(provider)
|
|
97
|
+
if not keyring_key:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
key = keyring.get_password(KEYRING_SERVICE, keyring_key)
|
|
101
|
+
return key if key else None
|
|
102
|
+
except Exception:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def remove_key(provider: str) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
Delete an LLM API key from the OS keychain.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
``True`` if deleted, ``False`` if it did not exist or failed.
|
|
112
|
+
"""
|
|
113
|
+
keyring_key = _PROVIDER_KEYRING_MAP.get(provider)
|
|
114
|
+
if not keyring_key:
|
|
115
|
+
return False
|
|
116
|
+
try:
|
|
117
|
+
keyring.delete_password(KEYRING_SERVICE, keyring_key)
|
|
118
|
+
return True
|
|
119
|
+
except keyring.errors.PasswordDeleteError:
|
|
120
|
+
return False # already absent
|
|
121
|
+
except Exception:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def has_key(provider: str) -> bool:
|
|
126
|
+
"""Return ``True`` if a key is stored for the given provider."""
|
|
127
|
+
return LLMKeyManager.retrieve_key(provider) is not None
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Discovery
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def get_configured_providers() -> list[str]:
|
|
135
|
+
"""
|
|
136
|
+
Return a list of provider names that have keys stored.
|
|
137
|
+
|
|
138
|
+
Example: ``["anthropic", "openai"]`` or ``["openai"]``.
|
|
139
|
+
"""
|
|
140
|
+
return [p for p in _PROVIDER_KEYRING_MAP if LLMKeyManager.has_key(p)]
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def mask_key(api_key: str) -> str:
|
|
144
|
+
"""
|
|
145
|
+
Return a masked representation of an API key for display.
|
|
146
|
+
|
|
147
|
+
Shows the first 8 characters followed by ``...``.
|
|
148
|
+
"""
|
|
149
|
+
if len(api_key) > 12:
|
|
150
|
+
return api_key[:8] + "..."
|
|
151
|
+
return "***"
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def mask_key_preview(api_key: str) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Return a short preview of an existing key: first 8 chars + "...." + last 4.
|
|
157
|
+
|
|
158
|
+
Used when prompting whether to keep, update, or delete an existing key.
|
|
159
|
+
"""
|
|
160
|
+
if len(api_key) <= 12:
|
|
161
|
+
return "***"
|
|
162
|
+
return api_key[:8] + "...." + api_key[-4:]
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Validation
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def validate_key(provider: str, api_key: str) -> tuple[bool, str]:
|
|
170
|
+
"""
|
|
171
|
+
Validate an LLM API key by making a minimal API call.
|
|
172
|
+
|
|
173
|
+
Uses raw ``httpx`` calls so the CLI does not require the full
|
|
174
|
+
``anthropic`` / ``openai`` SDK packages.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
provider: ``"anthropic"`` or ``"openai"``.
|
|
178
|
+
api_key: The key to validate.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Tuple of ``(is_valid, message)``. ``is_valid`` is ``True``
|
|
182
|
+
when the API accepted the key (the response may still contain
|
|
183
|
+
a model error, but auth succeeded).
|
|
184
|
+
"""
|
|
185
|
+
if provider == "anthropic":
|
|
186
|
+
return LLMKeyManager._validate_anthropic(api_key)
|
|
187
|
+
elif provider == "openai":
|
|
188
|
+
return LLMKeyManager._validate_openai(api_key)
|
|
189
|
+
return False, f"Unknown provider: {provider}"
|
|
190
|
+
|
|
191
|
+
# -- Anthropic --
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _validate_anthropic(api_key: str) -> tuple[bool, str]:
|
|
195
|
+
"""
|
|
196
|
+
Send a minimal request to the Anthropic Messages API.
|
|
197
|
+
|
|
198
|
+
A 200/400 with a model-related error means the key is valid;
|
|
199
|
+
a 401 means it is rejected.
|
|
200
|
+
"""
|
|
201
|
+
headers = {
|
|
202
|
+
"x-api-key": api_key,
|
|
203
|
+
"anthropic-version": "2023-06-01",
|
|
204
|
+
"content-type": "application/json",
|
|
205
|
+
}
|
|
206
|
+
payload = {
|
|
207
|
+
"model": "claude-haiku-4-5",
|
|
208
|
+
"max_tokens": 5,
|
|
209
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
210
|
+
}
|
|
211
|
+
try:
|
|
212
|
+
resp = httpx.post(
|
|
213
|
+
_ANTHROPIC_MESSAGES_URL,
|
|
214
|
+
headers=headers,
|
|
215
|
+
json=payload,
|
|
216
|
+
timeout=15.0,
|
|
217
|
+
)
|
|
218
|
+
if resp.status_code == 200:
|
|
219
|
+
return True, "Key is valid (Anthropic)."
|
|
220
|
+
if resp.status_code == 401:
|
|
221
|
+
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
222
|
+
msg = body.get("error", {}).get("message", "Invalid API key.")
|
|
223
|
+
return False, f"Authentication failed: {msg}"
|
|
224
|
+
# Only 200 means valid. 400/404/429/etc. are not accepted as proof the key works.
|
|
225
|
+
return False, f"Validation failed (HTTP {resp.status_code}). Key could not be verified."
|
|
226
|
+
except httpx.TimeoutException:
|
|
227
|
+
return False, "Validation timed out — could not reach Anthropic API."
|
|
228
|
+
except httpx.ConnectError:
|
|
229
|
+
return False, "Could not connect to Anthropic API."
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
return False, f"Validation error: {exc}"
|
|
232
|
+
|
|
233
|
+
# -- OpenAI --
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _validate_openai(api_key: str) -> tuple[bool, str]:
|
|
237
|
+
"""
|
|
238
|
+
Send a minimal request to the OpenAI Chat Completions API.
|
|
239
|
+
|
|
240
|
+
A 200 means valid; a 401 means invalid key.
|
|
241
|
+
"""
|
|
242
|
+
headers = {
|
|
243
|
+
"Authorization": f"Bearer {api_key}",
|
|
244
|
+
"Content-Type": "application/json",
|
|
245
|
+
}
|
|
246
|
+
payload = {
|
|
247
|
+
"model": "gpt-5.2",
|
|
248
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
249
|
+
"max_tokens": 5,
|
|
250
|
+
}
|
|
251
|
+
try:
|
|
252
|
+
resp = httpx.post(
|
|
253
|
+
_OPENAI_CHAT_URL,
|
|
254
|
+
headers=headers,
|
|
255
|
+
json=payload,
|
|
256
|
+
timeout=15.0,
|
|
257
|
+
)
|
|
258
|
+
if resp.status_code == 200:
|
|
259
|
+
return True, "Key is valid (OpenAI)."
|
|
260
|
+
if resp.status_code == 401:
|
|
261
|
+
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
262
|
+
msg = body.get("error", {}).get("message", "Invalid API key.")
|
|
263
|
+
return False, f"Authentication failed: {msg}"
|
|
264
|
+
# Only 200 means valid. 400/404/429/etc. are not accepted as proof the key works.
|
|
265
|
+
return False, f"Validation failed (HTTP {resp.status_code}). Key could not be verified."
|
|
266
|
+
except httpx.TimeoutException:
|
|
267
|
+
return False, "Validation timed out — could not reach OpenAI API."
|
|
268
|
+
except httpx.ConnectError:
|
|
269
|
+
return False, "Could not connect to OpenAI API."
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
return False, f"Validation error: {exc}"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Data model for account information returned by the API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class AccountInfo:
|
|
8
|
+
"""Minimal account information returned after token verification."""
|
|
9
|
+
|
|
10
|
+
account_uuid: str
|
|
11
|
+
account_name: str
|
|
12
|
+
email: str # Masked by the server (e.g. j***@example.com)
|
|
13
|
+
token_name: str = ""
|