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