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.
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 +118 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +25 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1241 -0
  9. codedd_cli/auditor/architecture_prompts.py +171 -0
  10. codedd_cli/auditor/complexity_analyzer.py +942 -0
  11. codedd_cli/auditor/dependency_scanner.py +2478 -0
  12. codedd_cli/auditor/file_auditor.py +572 -0
  13. codedd_cli/auditor/git_stats_collector.py +332 -0
  14. codedd_cli/auditor/response_parser.py +487 -0
  15. codedd_cli/auditor/vulnerability_validator.py +324 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +41 -0
  18. codedd_cli/auth/token_manager.py +87 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1877 -0
  22. codedd_cli/commands/audits_cmd.py +273 -0
  23. codedd_cli/commands/auth_cmd.py +230 -0
  24. codedd_cli/commands/config_cmd.py +454 -0
  25. codedd_cli/commands/scope_cmd.py +967 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +369 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +271 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +32 -0
  34. codedd_cli/models/local_directory.py +26 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +247 -0
  37. codedd_cli/scanner/file_walker.py +207 -0
  38. codedd_cli/scanner/line_counter.py +75 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +179 -0
  41. codedd_cli/utils/display.py +493 -0
  42. codedd_cli/utils/payload_inspector.py +180 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.0.dist-info/METADATA +276 -0
  46. codedd_cli-0.1.0.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.0.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
  49. codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,4 @@
1
+ from codedd_cli.config.settings import ConfigManager
2
+ from codedd_cli.config.constants import DEFAULT_API_URL, APP_NAME, CONFIG_DIR_NAME
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,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,5 @@
1
+ from codedd_cli.models.audit import Audit, GroupAudit
2
+ from codedd_cli.models.account import AccountInfo
3
+ from codedd_cli.models.local_directory import LocalDirectory
4
+
5
+ __all__ = ["Audit", "GroupAudit", "AccountInfo", "LocalDirectory"]
@@ -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 = ""