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,497 @@
1
+ """
2
+ Rich console output helpers for consistent terminal formatting.
3
+
4
+ On Windows, Unicode symbols (e.g. checkmark) often render as "?" in PowerShell,
5
+ so we use ASCII fallbacks there for reliable display.
6
+ """
7
+
8
+ import sys
9
+ from datetime import datetime
10
+
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.prompt import IntPrompt
14
+ from rich.table import Table
15
+
16
+ from codedd_cli import __version__
17
+ from codedd_cli.models.audit import Audit, GroupAudit
18
+ from codedd_cli.models.local_directory import LocalDirectory
19
+
20
+ # Use ASCII symbols on Windows so PowerShell/legacy consoles don't show "?"
21
+ if sys.platform == "win32":
22
+ SYMBOL_OK = "[+]"
23
+ SYMBOL_FAIL = "[x]"
24
+ SYMBOL_WARN = "[!]"
25
+ SYMBOL_INFO = "[i]"
26
+ SYMBOL_DIR = ">"
27
+ else:
28
+ SYMBOL_OK = "\u2713" # ✓
29
+ SYMBOL_FAIL = "\u2717" # ✗
30
+ SYMBOL_WARN = "!"
31
+ SYMBOL_INFO = "\u2139" # ℹ
32
+ SYMBOL_DIR = "\u25b8" # ▸ (right-pointing triangle, folder-like)
33
+
34
+ console = Console()
35
+
36
+ # Brand teal colour used in the CodeDD logo (for Rich inline styles).
37
+ BRAND_TEAL = "#2BBBC0"
38
+
39
+ # Style for audit progress debug logs (light grey so they don't compete with main progress).
40
+ STYLE_DEBUG_LOG = "grey74"
41
+
42
+
43
+ def print_banner() -> None:
44
+ """
45
+ Print the CodeDD CLI banner with an ASCII logo and welcome message.
46
+
47
+ The logo mirrors the CodeDD brand mark: a teal C-bracket with three
48
+ coloured dots (red, amber, green) inside the opening, followed by the
49
+ product name in spaced letters.
50
+
51
+ Intended for top-level ``codedd --help`` output only.
52
+ """
53
+ t = BRAND_TEAL
54
+ logo = (
55
+ f" [bold {t}] ██████╗[/]\n"
56
+ f" [bold {t}] ██╔════╝[/]\n"
57
+ f" [bold {t}] ██║[/] [bold #E84D4D]●[/] [bold #E8A838]●[/] [bold #4DB84D]●[/]"
58
+ + " [bold white]C o d e D D[/]"
59
+ + f" | [dim]v{__version__}[/dim] [bold]Welcome to CodeDD CLI![/bold] \n"
60
+ f" [bold {t}] ██╚════╗[/]\n"
61
+ f" [bold {t}] ██████╝[/]"
62
+ )
63
+ console.print()
64
+ console.print(logo, highlight=False)
65
+ console.print()
66
+
67
+
68
+ def print_success(message: str) -> None:
69
+ console.print(f"[bold green]{SYMBOL_OK}[/bold green] {message}")
70
+
71
+
72
+ def print_error(message: str) -> None:
73
+ console.print(f"[bold red]{SYMBOL_FAIL}[/bold red] {message}")
74
+
75
+
76
+ def print_warning(message: str) -> None:
77
+ console.print(f"[bold yellow]{SYMBOL_WARN}[/bold yellow] {message}")
78
+
79
+
80
+ def print_info(message: str) -> None:
81
+ console.print(f"[dim]{SYMBOL_INFO}[/dim] {message}")
82
+
83
+
84
+ def _format_date(iso_str: str | None) -> str:
85
+ """Convert an ISO datetime string to a human-friendly short date."""
86
+ if not iso_str:
87
+ return "-" if sys.platform == "win32" else "\u2014" # em dash
88
+ try:
89
+ dt = datetime.fromisoformat(iso_str)
90
+ return dt.strftime("%Y-%m-%d %H:%M")
91
+ except (ValueError, TypeError):
92
+ return iso_str[:16] if len(iso_str) >= 16 else iso_str
93
+
94
+
95
+ def _status_color(status: str) -> str:
96
+ """Map audit status strings to Rich color markup."""
97
+ lower = status.lower() if status else ""
98
+ if "completed" in lower or "complete" in lower:
99
+ return f"[green]{status}[/green]"
100
+ if "progress" in lower or "running" in lower or "processing" in lower:
101
+ return f"[yellow]{status}[/yellow]"
102
+ if "error" in lower or "failed" in lower:
103
+ return f"[red]{status}[/red]"
104
+ if "cancelled" in lower or "canceled" in lower:
105
+ return f"[dim]{status}[/dim]"
106
+ return status
107
+
108
+
109
+ def _format_number(n: int) -> str:
110
+ """Format an integer with thousand separators."""
111
+ return f"{n:,}"
112
+
113
+
114
+ def _type_cell(audit_type: str) -> str:
115
+ """
116
+ Format audit type for the table. On Windows (e.g. PowerShell), Rich
117
+ markup in table cells can render poorly, so use plain text there.
118
+ """
119
+ if sys.platform == "win32":
120
+ return audit_type # "group" or "single", no markup
121
+ if audit_type == "group":
122
+ return "[magenta]group[/magenta]"
123
+ return "[cyan]single[/cyan]"
124
+
125
+
126
+ def render_audits_table(
127
+ single_audits: list[Audit],
128
+ group_audits: list[GroupAudit],
129
+ total: int,
130
+ active_audit_uuid: str | None = None,
131
+ ) -> None:
132
+ """
133
+ Render a Rich table combining single and group audits.
134
+
135
+ Each row shows: index, type, name, status, repos/files, LoC, date.
136
+ If active_audit_uuid is set, the matching row is marked as the current selection.
137
+ """
138
+ table = Table(
139
+ title=f"Your Audits ({total} total)",
140
+ show_lines=False,
141
+ padding=(0, 1),
142
+ expand=True,
143
+ )
144
+ table.add_column("#", style="dim", width=4, justify="right")
145
+ table.add_column("Type", width=16) # e.g. "group -> active"
146
+ table.add_column("Name", min_width=20, max_width=40, no_wrap=True)
147
+ table.add_column("Status", min_width=12)
148
+ table.add_column("Files", justify="right", width=8)
149
+ table.add_column("LoC", justify="right", width=10)
150
+ table.add_column("Date", width=18)
151
+ table.add_column("UUID", style="dim", width=12)
152
+
153
+ idx = 1
154
+
155
+ def _type_cell_with_active(audit_type: str, uuid: str) -> str:
156
+ """Type cell; show 'type -> active' when this audit is the active one."""
157
+ base = _type_cell(audit_type)
158
+ if active_audit_uuid and uuid == active_audit_uuid:
159
+ suffix = " -> active"
160
+ if sys.platform == "win32":
161
+ return audit_type + suffix
162
+ return base + "[bold green]" + suffix + "[/bold green]"
163
+ return base
164
+
165
+ # Group audits first
166
+ for ga in group_audits:
167
+ table.add_row(
168
+ str(idx),
169
+ _type_cell_with_active("group", ga.audit_uuid),
170
+ ga.audit_name or "—",
171
+ _status_color(ga.audit_status),
172
+ _format_number(ga.number_files),
173
+ _format_number(ga.lines_of_code),
174
+ _format_date(ga.ai_synthesis),
175
+ ga.audit_uuid[:8] + "…",
176
+ )
177
+ idx += 1
178
+
179
+ # Single audits
180
+ for a in single_audits:
181
+ table.add_row(
182
+ str(idx),
183
+ _type_cell_with_active("single", a.audit_uuid),
184
+ a.audit_name or "—",
185
+ _status_color(a.audit_status),
186
+ _format_number(a.number_files),
187
+ _format_number(a.lines_of_code),
188
+ _format_date(a.ai_synthesis),
189
+ a.audit_uuid[:8] + "…",
190
+ )
191
+ idx += 1
192
+
193
+ console.print(table)
194
+
195
+
196
+ def render_audit_detail(audit) -> None:
197
+ """Render a detailed panel for a single audit or group audit."""
198
+ if isinstance(audit, GroupAudit):
199
+ content = (
200
+ f"[bold]Name:[/bold] {audit.audit_name}\n"
201
+ f"[bold]Type:[/bold] Group Audit\n"
202
+ f"[bold]Status:[/bold] {_status_color(audit.audit_status)}\n"
203
+ f"[bold]Sub-audits:[/bold] {audit.number_of_sub_audits}\n"
204
+ f"[bold]Files:[/bold] {_format_number(audit.number_files)}\n"
205
+ f"[bold]LoC:[/bold] {_format_number(audit.lines_of_code)}\n"
206
+ f"[bold]Date:[/bold] {_format_date(audit.ai_synthesis)}\n"
207
+ f"[bold]UUID:[/bold] {audit.audit_uuid}"
208
+ )
209
+ elif isinstance(audit, Audit):
210
+ content = (
211
+ f"[bold]Name:[/bold] {audit.audit_name}\n"
212
+ f"[bold]Type:[/bold] Single Audit\n"
213
+ f"[bold]Status:[/bold] {_status_color(audit.audit_status)}\n"
214
+ f"[bold]Repo:[/bold] {audit.repo_url or '—'}\n"
215
+ f"[bold]Files:[/bold] {_format_number(audit.number_files)}\n"
216
+ f"[bold]LoC:[/bold] {_format_number(audit.lines_of_code)}\n"
217
+ f"[bold]Date:[/bold] {_format_date(audit.ai_synthesis)}\n"
218
+ f"[bold]UUID:[/bold] {audit.audit_uuid}"
219
+ )
220
+ else:
221
+ content = str(audit)
222
+
223
+ console.print(Panel(content, title="Audit Details", expand=False))
224
+
225
+
226
+ def render_scope_table(directories: list[dict[str, str]], audit_name: str = "") -> None:
227
+ """
228
+ Render a Rich table showing the directories currently in the audit scope.
229
+
230
+ Args:
231
+ directories: List of dicts from ``ConfigManager.scope_directories``.
232
+ audit_name: Active audit name for the table title.
233
+ """
234
+ title = f"Audit Scope — {audit_name}" if audit_name else "Audit Scope"
235
+ table = Table(title=title, show_lines=False, padding=(0, 1), expand=True)
236
+ table.add_column("#", style="dim", width=4, justify="right")
237
+ table.add_column("Repository", min_width=16, max_width=30, no_wrap=True)
238
+ table.add_column("Branch", width=18)
239
+ table.add_column("Commit", width=10)
240
+ table.add_column("Path", min_width=30)
241
+
242
+ for idx, entry in enumerate(directories, start=1):
243
+ table.add_row(
244
+ str(idx),
245
+ f"[bold]{entry.get('repo_name', '—')}[/bold]",
246
+ entry.get("branch", "—"),
247
+ f"[dim]{entry.get('commit_hash', '—')}[/dim]",
248
+ entry.get("path", "—"),
249
+ )
250
+
251
+ console.print(table)
252
+
253
+ if not directories:
254
+ console.print(
255
+ "[dim] No directories added yet. Use [bold cyan]codedd scope add <path>[/bold cyan] to add one.[/dim]"
256
+ )
257
+
258
+
259
+ def render_scope_table_with_sync(directories: list[dict[str, str]], audit_name: str = "") -> None:
260
+ """
261
+ Render the scope table with additional columns showing sync state
262
+ (confirmed, needs_reconfirm, last_sync).
263
+ """
264
+ title = f"Audit Scope — {audit_name}" if audit_name else "Audit Scope"
265
+ table = Table(title=title, show_lines=False, padding=(0, 1), expand=True)
266
+ table.add_column("#", style="dim", width=4, justify="right")
267
+ table.add_column("Repository", min_width=16, max_width=25, no_wrap=True)
268
+ table.add_column("Branch", width=14)
269
+ table.add_column("Confirmed", width=10)
270
+ table.add_column("Sync", width=16)
271
+ table.add_column("Path", min_width=25)
272
+
273
+ for idx, entry in enumerate(directories, start=1):
274
+ confirmed = entry.get("confirmed", False)
275
+ needs_reconfirm = entry.get("needs_reconfirm", False)
276
+ last_sync = entry.get("last_sync", "")
277
+
278
+ if needs_reconfirm:
279
+ status_str = "[bold yellow]dirty[/bold yellow]"
280
+ elif confirmed:
281
+ status_str = "[bold green]yes[/bold green]"
282
+ else:
283
+ status_str = "[dim]no[/dim]"
284
+
285
+ sync_str = last_sync[:16] if last_sync else "[dim]never[/dim]"
286
+
287
+ table.add_row(
288
+ str(idx),
289
+ f"[bold]{entry.get('repo_name', '-')}[/bold]",
290
+ entry.get("branch", "-"),
291
+ status_str,
292
+ sync_str,
293
+ entry.get("path", "-"),
294
+ )
295
+
296
+ console.print(table)
297
+
298
+ if not directories:
299
+ console.print(
300
+ "[dim] No directories added yet. Use [bold cyan]codedd scope add <path>[/bold cyan] to add one.[/dim]"
301
+ )
302
+
303
+
304
+ # Maximum number of file rows to show in the diff table; rest are summarized.
305
+ DIFF_TABLE_MAX_ROWS = 10
306
+
307
+
308
+ def render_diff_table(
309
+ repo_name: str,
310
+ diff: dict,
311
+ remote_file_count: int | None = None,
312
+ max_rows: int = DIFF_TABLE_MAX_ROWS,
313
+ ) -> None:
314
+ """
315
+ Render a Rich table showing file-level differences between the
316
+ local scan and the remote (CodeDD) state.
317
+
318
+ When remote has 0 files (audit removed in CodeDD), only a short message
319
+ is shown and no table is rendered. Otherwise a one-line summary, up to
320
+ max_rows table rows, and a "+ N more files" footer are shown.
321
+
322
+ Args:
323
+ repo_name: Human-readable repository name.
324
+ diff: Dict with keys ``added``, ``removed``, ``changed``.
325
+ - added: list of ``{"path", "lines_of_code"}``
326
+ - removed: list of ``{"path", "lines_of_code"}``
327
+ - changed: list of ``{"path", "old_loc", "new_loc"}``
328
+ remote_file_count: Number of files on remote. If 0, show "audit removed" message only.
329
+ max_rows: Maximum table rows to show; remaining count shown as "+ N more files".
330
+ """
331
+ added = diff.get("added", [])
332
+ removed = diff.get("removed", [])
333
+ changed = diff.get("changed", [])
334
+
335
+ if not added and not removed and not changed:
336
+ return
337
+
338
+ # Remote has no files: audit was removed or no files selected in CodeDD.
339
+ if remote_file_count is not None and remote_file_count == 0:
340
+ console.print(
341
+ f" [dim]{SYMBOL_INFO}[/dim] [bold]{repo_name}[/bold]: "
342
+ "Audit has been removed from CodeDD or no files are selected for audit."
343
+ )
344
+ return
345
+
346
+ dash = "-" if sys.platform == "win32" else "\u2014"
347
+
348
+ # Build unified row list: (status_label, path, old_loc, new_loc)
349
+ rows: list[tuple[str, str, str, str]] = []
350
+ for f in added:
351
+ rows.append(("[green]+ Added[/green]", f["path"], dash, str(f.get("lines_of_code", 0))))
352
+ for f in removed:
353
+ rows.append(("[red]- Removed[/red]", f["path"], str(f.get("lines_of_code", 0)), dash))
354
+ for f in changed:
355
+ rows.append(
356
+ (
357
+ "[yellow]~ Changed[/yellow]",
358
+ f["path"],
359
+ str(f.get("old_loc", 0)),
360
+ str(f.get("new_loc", 0)),
361
+ )
362
+ )
363
+
364
+ total = len(rows)
365
+ n_added, n_removed, n_changed = len(added), len(removed), len(changed)
366
+ loc_old = sum(f.get("lines_of_code", 0) for f in removed) + sum(f.get("old_loc", 0) for f in changed)
367
+ loc_new = sum(f.get("lines_of_code", 0) for f in added) + sum(f.get("new_loc", 0) for f in changed)
368
+ loc_delta_added = max(0, loc_new - loc_old)
369
+ loc_delta_removed = max(0, loc_old - loc_new)
370
+
371
+ # Summary line: file counts and total LoC added/removed
372
+ summary_parts = [f"{n_added} added", f"{n_removed} removed", f"{n_changed} changed"]
373
+ summary_parts.append(f"+{loc_delta_added:,} LoC added, -{loc_delta_removed:,} LoC removed")
374
+ console.print(f" [bold]{repo_name}[/bold]: {', '.join(summary_parts)}")
375
+
376
+ # Table: at most max_rows
377
+ shown = rows[:max_rows]
378
+ table = Table(
379
+ title=f"Diff: {repo_name}",
380
+ show_lines=False,
381
+ padding=(0, 1),
382
+ expand=True,
383
+ )
384
+ table.add_column("Status", width=10)
385
+ table.add_column("File", min_width=30)
386
+ table.add_column("Old LoC", justify="right", width=10)
387
+ table.add_column("New LoC", justify="right", width=10)
388
+
389
+ for status_label, path, old_loc, new_loc in shown:
390
+ table.add_row(status_label, path, old_loc, new_loc)
391
+
392
+ console.print(table)
393
+
394
+ if total > max_rows:
395
+ console.print(f" [dim]+ {total - max_rows} more file{'s' if total - max_rows != 1 else ''} have changed[/dim]")
396
+
397
+
398
+ def prompt_deleted_audits_action(repo_names: list[str]) -> int:
399
+ """
400
+ Display a panel listing audits that have been deleted on CodeDD and prompt
401
+ the user to choose an action.
402
+
403
+ When more than one audit is deleted, options 3 and 4 are shown to make
404
+ "accept all" and "restore all" explicit.
405
+
406
+ Returns:
407
+ 1 = Confirm remote audit deletion (remove from local scope).
408
+ 2 = Restore local audit scope and sync to CodeDD (re-register).
409
+ 3 = Accept all deletions locally (same as 1; only when len(repo_names) > 1).
410
+ 4 = Restore all audits to CodeDD (same as 2; only when len(repo_names) > 1).
411
+ """
412
+ # Local directories (repos) with icon and color
413
+ repo_list = "\n".join(f" [cyan]{SYMBOL_DIR}[/cyan] [bold cyan]{name}[/bold cyan]" for name in sorted(repo_names))
414
+ multiple = len(repo_names) > 1
415
+
416
+ # Options 1/3 = accept deletion (yellow); 2/4 = restore (green); descriptions dim
417
+ options_text = (
418
+ " [bold yellow]1[/bold yellow] Confirm remote audit deletion\n"
419
+ " [dim]Remove these from your local scope so it matches CodeDD.[/dim]\n\n"
420
+ " [bold green]2[/bold green] Restore local audit scope and sync to CodeDD\n"
421
+ " [dim]Re-register the scope to recreate these audits on CodeDD.[/dim]"
422
+ )
423
+ choices_list = ["1", "2"]
424
+
425
+ if multiple:
426
+ options_text += (
427
+ "\n\n"
428
+ " [bold yellow]3[/bold yellow] Accept all deletions locally\n"
429
+ " [dim]Same as (1): remove all listed audits from local scope.[/dim]\n\n"
430
+ " [bold green]4[/bold green] Restore all audits to CodeDD\n"
431
+ " [dim]Same as (2): re-register scope to recreate all listed audits on CodeDD.[/dim]"
432
+ )
433
+ choices_list = ["1", "2", "3", "4"]
434
+
435
+ content = (
436
+ "[bold]The following audit(s) no longer exist on CodeDD (deleted or cleared):[/bold]\n\n"
437
+ f"{repo_list}\n\n"
438
+ "[bold]What do you want to do?[/bold]\n\n"
439
+ f"{options_text}"
440
+ )
441
+ console.print()
442
+ console.print(
443
+ Panel(
444
+ content, title="[bold yellow]Audits deleted on CodeDD[/bold yellow]", border_style="yellow", padding=(1, 2)
445
+ )
446
+ )
447
+ console.print()
448
+ choice = IntPrompt.ask("Enter your choice", choices=choices_list, default=1)
449
+ return choice
450
+
451
+
452
+ # Base URL for the CodeDD cloud application (no trailing slash).
453
+ CODEDD_BASE_URL = "https://www.codedd.ai"
454
+
455
+
456
+ def print_audit_scope_cloud_info(audit_uuid: str, audit_type: str) -> None:
457
+ """
458
+ Display an info message after scope confirm/re-confirm with the URL to manage
459
+ the audit scope in the CodeDD cloud application.
460
+
461
+ Args:
462
+ audit_uuid: The active audit UUID (group or single).
463
+ audit_type: Either "group" or "single" to build the correct path.
464
+ """
465
+ if audit_type == "group":
466
+ path = "audit-invitation"
467
+ else:
468
+ path = "audit-scope-selection"
469
+ url = f"{CODEDD_BASE_URL}/{audit_uuid}/{path}"
470
+
471
+ content = (
472
+ "You can manage this audit in the CodeDD cloud application:\n\n"
473
+ " • Adapt which files are selected for the audit\n"
474
+ " • Delete scoped directories from the audit\n"
475
+ " • Pay for the audit and start the analysis\n\n"
476
+ f"[bold cyan]{url}[/bold cyan]"
477
+ )
478
+ console.print()
479
+ console.print(Panel(content, title="[bold]Audit scope at CodeDD[/bold]", border_style="blue", padding=(1, 2)))
480
+ console.print()
481
+
482
+
483
+ def render_validation_result(dir_info: LocalDirectory) -> None:
484
+ """
485
+ Display a single directory validation result (success or error).
486
+
487
+ Args:
488
+ dir_info: A validated ``LocalDirectory`` instance.
489
+ """
490
+ if dir_info.is_valid:
491
+ console.print(
492
+ f" [bold green]{SYMBOL_OK}[/bold green] [bold]{dir_info.repo_name}[/bold] "
493
+ f"[dim]({dir_info.branch} @ {dir_info.commit_hash})[/dim] "
494
+ f"{dir_info.path}"
495
+ )
496
+ else:
497
+ console.print(f" [bold red]{SYMBOL_FAIL}[/bold red] {dir_info.path}\n [red]{dir_info.error}[/red]")
@@ -0,0 +1,178 @@
1
+ """
2
+ General-purpose payload inspection for the ``--show`` flag.
3
+
4
+ Any CLI command that sends data to CodeDD can use this module to:
5
+ 1. Write the outgoing payload or request to a human-readable ``.txt`` file.
6
+ 2. Open the file for the user to review.
7
+ 3. Ask for explicit confirmation before sending.
8
+
9
+ This ensures full transparency: users always know **exactly** what
10
+ data leaves their machine.
11
+
12
+ Use :func:`review_payload` for POST bodies (e.g. scope registration).
13
+ Use :func:`review_request` for GET/POST request summaries (method, endpoint, params, body).
14
+
15
+ Usage example::
16
+
17
+ from codedd_cli.utils.payload_inspector import review_payload, review_request
18
+
19
+ # POST with JSON body
20
+ if not review_payload(payload, command_label="Scope Registration"):
21
+ raise typer.Exit()
22
+
23
+ # GET or request summary
24
+ if not review_request("GET", "/api/cli/scope/files/", params={"audit_uuid": u}):
25
+ raise typer.Exit()
26
+ """
27
+
28
+ import json
29
+ import os
30
+ import platform
31
+ import subprocess
32
+ import tempfile
33
+ from datetime import datetime, timezone
34
+ from typing import Any
35
+
36
+ from rich.console import Console
37
+ from rich.prompt import Confirm
38
+
39
+ console = Console()
40
+
41
+
42
+ def write_payload_file(
43
+ payload: Any,
44
+ *,
45
+ command_label: str = "API Request",
46
+ context_note: str = "This file contains ONLY metadata. No source code content is included.",
47
+ ) -> str:
48
+ """
49
+ Serialise *payload* to a formatted ``.txt`` file in the system temp directory.
50
+
51
+ The file includes a human-readable header, the context note, and the
52
+ full JSON payload.
53
+
54
+ Args:
55
+ payload: Any JSON-serialisable object (dict, list, etc.).
56
+ command_label: Short label shown in the file header (e.g. ``"Scope Registration"``).
57
+ context_note: Explanatory text placed before the JSON body.
58
+
59
+ Returns:
60
+ Absolute path to the written file.
61
+ """
62
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
63
+ safe_label = "".join(c if c.isalnum() or c in "-_" else "_" for c in command_label)
64
+ filename = f"codedd_{safe_label}_{timestamp}.txt"
65
+ filepath = os.path.join(tempfile.gettempdir(), filename)
66
+
67
+ with open(filepath, "w", encoding="utf-8") as fh:
68
+ fh.write("=" * 72 + "\n")
69
+ fh.write(f" CodeDD CLI — {command_label}\n")
70
+ fh.write(f" Generated: {datetime.now(timezone.utc).isoformat()}\n")
71
+ fh.write("=" * 72 + "\n\n")
72
+ if context_note:
73
+ fh.write(context_note + "\n\n")
74
+ fh.write("-" * 72 + "\n\n")
75
+ json.dump(payload, fh, indent=2, ensure_ascii=False, default=str)
76
+ fh.write("\n")
77
+
78
+ return filepath
79
+
80
+
81
+ def open_file(filepath: str) -> None:
82
+ """
83
+ Attempt to open *filepath* with the operating system's default viewer.
84
+
85
+ Fails silently — the path is always printed separately so the user
86
+ can open it manually.
87
+ """
88
+ try:
89
+ system = platform.system()
90
+ if system == "Windows":
91
+ os.startfile(filepath) # type: ignore[attr-defined]
92
+ elif system == "Darwin":
93
+ subprocess.run(["open", filepath], check=False)
94
+ else:
95
+ subprocess.run(["xdg-open", filepath], check=False)
96
+ except Exception:
97
+ pass
98
+
99
+
100
+ def review_payload(
101
+ payload: Any,
102
+ *,
103
+ command_label: str = "API Request",
104
+ context_note: str = "This file contains ONLY metadata. No source code content is included.",
105
+ confirm_prompt: str = "Proceed with sending this data to CodeDD?",
106
+ ) -> bool:
107
+ """
108
+ Write the payload to a file, open it, and ask the user for confirmation.
109
+
110
+ This is the **one-call** convenience function that combines
111
+ :func:`write_payload_file`, :func:`open_file`, and a ``Confirm`` prompt.
112
+
113
+ Args:
114
+ payload: JSON-serialisable data.
115
+ command_label: Label for the file header.
116
+ context_note: Transparency note shown in the file.
117
+ confirm_prompt: Question text for the confirmation prompt.
118
+
119
+ Returns:
120
+ ``True`` if the user confirmed, ``False`` if they declined.
121
+ """
122
+ filepath = write_payload_file(
123
+ payload,
124
+ command_label=command_label,
125
+ context_note=context_note,
126
+ )
127
+
128
+ console.print(f"\n[bold]Payload written to:[/bold] {filepath}\n")
129
+ console.print(f"[dim]Review the file above. {context_note}[/dim]")
130
+ open_file(filepath)
131
+ console.print()
132
+
133
+ return Confirm.ask(confirm_prompt, default=True)
134
+
135
+
136
+ def review_request(
137
+ method: str,
138
+ path: str,
139
+ *,
140
+ params: dict[str, Any] | None = None,
141
+ json_body: Any | None = None,
142
+ command_label: str = "API Request",
143
+ context_note: str = "This file describes the request that will be sent to CodeDD.",
144
+ confirm_prompt: str = "Proceed with sending this request to CodeDD?",
145
+ ) -> bool:
146
+ """
147
+ Build a request summary (method, path, params, body) and run the review flow.
148
+
149
+ Use for GET requests (params only), POST with body, or any call where you
150
+ want a single transparency file showing exactly what will be sent.
151
+
152
+ Args:
153
+ method: HTTP method (e.g. "GET", "POST").
154
+ path: API path (e.g. "/api/cli/scope/files/").
155
+ params: Optional query parameters (for GET or POST).
156
+ json_body: Optional JSON body (for POST/PUT).
157
+ command_label: Label for the file header.
158
+ context_note: Transparency note in the file.
159
+ confirm_prompt: Confirmation question.
160
+
161
+ Returns:
162
+ True if the user confirmed, False if they declined.
163
+ """
164
+ payload: dict[str, Any] = {
165
+ "method": method.upper(),
166
+ "endpoint": path,
167
+ }
168
+ if params:
169
+ payload["query_params"] = params
170
+ if json_body is not None:
171
+ payload["body"] = json_body
172
+
173
+ return review_payload(
174
+ payload,
175
+ command_label=command_label,
176
+ context_note=context_note,
177
+ confirm_prompt=confirm_prompt,
178
+ )
@@ -0,0 +1,14 @@
1
+ """
2
+ Security utilities for the CLI.
3
+ """
4
+
5
+
6
+ def mask_token(token: str) -> str:
7
+ """
8
+ Return a masked representation of a CLI token suitable for display.
9
+
10
+ Example: ``codedd_cli_...xYz9``
11
+ """
12
+ if not token or len(token) < 16:
13
+ return "***"
14
+ return f"codedd_cli_...{token[-4:]}"