ccid-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.
ccid_cli/main.py ADDED
@@ -0,0 +1,709 @@
1
+ # Copyright (c) 2024-2026 Dr. Fred Mpala. All rights reserved.
2
+ # Proprietary and confidential. Unauthorized copying, distribution,
3
+ # or use of this file, via any medium, is strictly prohibited.
4
+
5
+ """
6
+ CCID CLI — Pipeline-native compliance checking from the terminal.
7
+
8
+ Commands
9
+ --------
10
+ ccid configure Set API URL and auth token
11
+ ccid analyze <path> Run code analysis swarm
12
+ ccid posture Compliance posture snapshot
13
+ ccid assignments List open findings
14
+ ccid export <assessment-id> Export assessment report
15
+ ccid status API health check
16
+ ccid replay <assessment-id> Replay swarm reasoning chain
17
+ ccid patterns Manage golden patterns
18
+ ccid review-queue Review queue workspace
19
+ ccid validate-pattern <id> Validate a pattern against requirements
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ import typer
31
+ from rich.console import Console
32
+ from rich.live import Live
33
+ from rich.panel import Panel
34
+ from rich.status import Status
35
+ from rich.table import Table
36
+ from rich.text import Text
37
+
38
+ from ccid_cli.api import CCIDClient, load_config, save_config
39
+ from ccid_cli.formatters import (
40
+ console,
41
+ format_analysis_result,
42
+ format_assignments,
43
+ format_pattern_recommendations,
44
+ format_posture,
45
+ format_swarm_replay,
46
+ )
47
+
48
+ app = typer.Typer(
49
+ name="ccid",
50
+ help="CCID Compliance CLI - Pipeline-native compliance checking.",
51
+ no_args_is_help=True,
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # ccid configure
57
+ # ---------------------------------------------------------------------------
58
+
59
+ @app.command()
60
+ def configure() -> None:
61
+ """Set API URL and authentication token. Saves to ~/.ccid/config.json."""
62
+ cfg = load_config()
63
+
64
+ api_url = typer.prompt(
65
+ "CCID API URL",
66
+ default=cfg.get("api_url", "https://ccid-backend-922408154037.europe-west2.run.app"),
67
+ )
68
+ api_key = typer.prompt(
69
+ "API key / Bearer token",
70
+ default=cfg.get("api_key", ""),
71
+ hide_input=True,
72
+ )
73
+
74
+ cfg["api_url"] = api_url.rstrip("/")
75
+ cfg["api_key"] = api_key
76
+ save_config(cfg)
77
+ console.print("[green]Configuration saved to ~/.ccid/config.json[/green]")
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # ccid analyze
82
+ # ---------------------------------------------------------------------------
83
+
84
+ @app.command()
85
+ def analyze(
86
+ path: str = typer.Argument(..., help="Path to file or directory to analyse"),
87
+ framework: Optional[str] = typer.Option(
88
+ None,
89
+ "--framework",
90
+ "-f",
91
+ help="Comma-separated frameworks, e.g. SOC2,ISO27001",
92
+ ),
93
+ format: str = typer.Option(
94
+ "json", "--format", help="Output format: sarif, json, text"
95
+ ),
96
+ live: bool = typer.Option(
97
+ False, "--live", help="Stream progress via WebSocket"
98
+ ),
99
+ fail_on: Optional[str] = typer.Option(
100
+ None,
101
+ "--fail-on",
102
+ help="Exit non-zero on gate decision: 'warn' (exit on warn+block) or 'block' (exit on block only)",
103
+ ),
104
+ ) -> None:
105
+ """Run compliance code analysis on a file or directory.
106
+
107
+ Reads the target path and sends the content to the CCID code-analysis
108
+ swarm. Use --live to stream real-time agent progress via WebSocket.
109
+
110
+ Exit codes:
111
+ 0 = pass
112
+ 1 = block
113
+ 2 = warn
114
+ """
115
+ target = Path(path).expanduser().resolve()
116
+ if not target.exists():
117
+ console.print(f"[red]Path not found: {target}[/red]")
118
+ raise typer.Exit(code=1)
119
+
120
+ # Collect code content
121
+ if target.is_file():
122
+ code = target.read_text()
123
+ else:
124
+ # Concatenate all relevant IaC / code files
125
+ extensions = {".tf", ".hcl", ".yaml", ".yml", ".json", ".py", ".ts", ".js"}
126
+ parts: list[str] = []
127
+ for f in sorted(target.rglob("*")):
128
+ if f.is_file() and f.suffix in extensions:
129
+ try:
130
+ parts.append(f"# --- {f.relative_to(target)} ---\n{f.read_text()}")
131
+ except Exception:
132
+ pass
133
+ if not parts:
134
+ console.print("[red]No analysable files found in directory.[/red]")
135
+ raise typer.Exit(code=1)
136
+ code = "\n\n".join(parts)
137
+
138
+ frameworks = [s.strip() for s in framework.split(",")] if framework else None
139
+
140
+ client = CCIDClient()
141
+
142
+ # --live: stream via WebSocket
143
+ if live:
144
+ asyncio.run(_live_analyze(client, code, frameworks))
145
+ return
146
+
147
+ # Standard synchronous call
148
+ with console.status("[bold cyan]Running code analysis...[/bold cyan]"):
149
+ try:
150
+ result = client.analyze(code=code, frameworks=frameworks, format=format)
151
+ except Exception as exc:
152
+ console.print(f"[red]Analysis failed: {exc}[/red]")
153
+ raise typer.Exit(code=1)
154
+
155
+ if format == "sarif":
156
+ console.print_json(data=result)
157
+ elif format == "text":
158
+ format_analysis_result(result)
159
+ else:
160
+ format_analysis_result(result)
161
+
162
+ _exit_for_gate(result.get("gate_decision", "pass"), fail_on)
163
+
164
+
165
+ async def _live_analyze(
166
+ client: CCIDClient,
167
+ code: str,
168
+ frameworks: list[str] | None,
169
+ ) -> None:
170
+ """Connect WebSocket, submit analysis, stream node progress with Rich Live."""
171
+ import websockets
172
+
173
+ ws_url = client.ws_url
174
+ headers = {}
175
+ if client.api_key:
176
+ # X-API-Key is the header the backend /ws handler reads for key auth
177
+ # (TRU-583). NB: the WS handler currently validates only the system
178
+ # API_KEY here, not tenant-scoped ccid_ keys — tenant-key --live is a
179
+ # separate backend follow-up; this swap is the correct header regardless.
180
+ headers["X-API-Key"] = client.api_key
181
+
182
+ node_states: dict[str, dict] = {}
183
+
184
+ def _build_table() -> Table:
185
+ tbl = Table(title="CCID Live Analysis", show_lines=True, expand=True)
186
+ tbl.add_column("Node", style="bold", width=28)
187
+ tbl.add_column("Status", justify="center", width=12)
188
+ tbl.add_column("Tokens", justify="right", width=10)
189
+ tbl.add_column("Duration", justify="right", width=10)
190
+ for name, st in node_states.items():
191
+ status_str = st.get("status", "pending")
192
+ if status_str == "completed":
193
+ indicator = "[green]OK[/green]"
194
+ elif status_str == "running":
195
+ indicator = "[yellow]...[/yellow]"
196
+ elif status_str == "failed":
197
+ indicator = "[red]FAIL[/red]"
198
+ else:
199
+ indicator = "[dim]--[/dim]"
200
+ tokens = st.get("tokens", "")
201
+ duration = st.get("duration", "")
202
+ tbl.add_row(name, indicator, str(tokens), str(duration))
203
+ return tbl
204
+
205
+ try:
206
+ async with websockets.connect(
207
+ ws_url, additional_headers=headers
208
+ ) as ws:
209
+ # Submit analysis in the background via httpx
210
+ import httpx
211
+
212
+ async def _submit():
213
+ payload = {"terraform_code": code, "trigger_source": "cli"}
214
+ if frameworks:
215
+ payload["frameworks"] = frameworks
216
+ async with httpx.AsyncClient(
217
+ base_url=client.base_url,
218
+ headers=client._headers(),
219
+ timeout=120.0,
220
+ ) as http:
221
+ resp = await http.post(
222
+ "/api/agents/run-code-analysis", json=payload
223
+ )
224
+ resp.raise_for_status()
225
+ return resp.json()
226
+
227
+ submit_task = asyncio.create_task(_submit())
228
+
229
+ with Live(_build_table(), console=console, refresh_per_second=4) as live_display:
230
+ while not submit_task.done():
231
+ try:
232
+ raw = await asyncio.wait_for(ws.recv(), timeout=0.5)
233
+ msg = json.loads(raw) if isinstance(raw, str) else raw
234
+ event = msg.get("event", "")
235
+
236
+ if event == "agent_progress":
237
+ node_name = msg.get("node", msg.get("agent", "unknown"))
238
+ node_states[node_name] = {
239
+ "status": msg.get("status", "running"),
240
+ "tokens": msg.get("tokens", ""),
241
+ "duration": msg.get("duration", ""),
242
+ }
243
+ live_display.update(_build_table())
244
+
245
+ elif event in (
246
+ "code_analysis_completed",
247
+ "code_analysis_started",
248
+ ):
249
+ pass # handled by submit_task result
250
+
251
+ except asyncio.TimeoutError:
252
+ continue
253
+ except websockets.exceptions.ConnectionClosed:
254
+ break
255
+
256
+ result = await submit_task
257
+ format_analysis_result(result)
258
+
259
+ except Exception as exc:
260
+ console.print(f"[yellow]WebSocket unavailable ({exc}), falling back to sync...[/yellow]")
261
+ with console.status("[bold cyan]Running code analysis...[/bold cyan]"):
262
+ result = client.analyze(code=code, frameworks=frameworks)
263
+ format_analysis_result(result)
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # ccid posture
268
+ # ---------------------------------------------------------------------------
269
+
270
+ @app.command()
271
+ def posture(
272
+ scope: str = typer.Option(
273
+ "system", "--scope", "-s", help="Scope: system, framework, repo"
274
+ ),
275
+ scope_id: Optional[str] = typer.Option(
276
+ None, "--scope-id", help="Scope identifier (e.g. framework code)"
277
+ ),
278
+ format: str = typer.Option(
279
+ "text", "--format", help="Output format: json, sarif, text"
280
+ ),
281
+ fail_on: Optional[str] = typer.Option(
282
+ None,
283
+ "--fail-on",
284
+ help="Exit non-zero on posture gate: 'block' (exit 1) or 'warn' "
285
+ "(exit 2 on warn, 1 on block). 'none'/'off' = informational only. "
286
+ "Default: never fail.",
287
+ ),
288
+ ) -> None:
289
+ """Show compliance posture snapshot."""
290
+ client = CCIDClient()
291
+ with console.status("[bold cyan]Fetching posture...[/bold cyan]"):
292
+ data = client.posture(scope=scope, scope_id=scope_id)
293
+
294
+ if format == "json":
295
+ console.print_json(data=data)
296
+ elif format == "sarif":
297
+ console.print_json(data=data)
298
+ else:
299
+ format_posture(data)
300
+
301
+ # The composite action (.github/actions/ccid-compliance) invokes
302
+ # `ccid posture --fail-on <level>`; before TRU-583 posture had no such
303
+ # option so that step always exit-2'd. Map the posture verdict → gate.
304
+ _exit_for_gate(str(data.get("posture", "pass")).lower(), fail_on)
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # ccid assignments
309
+ # ---------------------------------------------------------------------------
310
+
311
+ @app.command()
312
+ def assignments(
313
+ severity: Optional[str] = typer.Option(
314
+ None,
315
+ "--severity",
316
+ help="Comma-separated severities, e.g. critical,high",
317
+ ),
318
+ status: str = typer.Option(
319
+ "open", "--status", help="Filter by status: open, resolved, all"
320
+ ),
321
+ format: str = typer.Option(
322
+ "text", "--format", help="Output format: json, text"
323
+ ),
324
+ ) -> None:
325
+ """List compliance findings / assignments."""
326
+ client = CCIDClient()
327
+ with console.status("[bold cyan]Fetching assignments...[/bold cyan]"):
328
+ data = client.assignments(severity=severity, status=status)
329
+
330
+ if format == "json":
331
+ console.print_json(data=data)
332
+ else:
333
+ format_assignments(data)
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # ccid export
338
+ # ---------------------------------------------------------------------------
339
+
340
+ @app.command()
341
+ def export(
342
+ assessment_id: str = typer.Argument(..., help="Assessment UUID"),
343
+ format: str = typer.Option(
344
+ "json", "--format", help="Export format: sarif, json, html, text"
345
+ ),
346
+ output: Optional[str] = typer.Option(
347
+ None, "--output", "-o", help="Write to file instead of stdout"
348
+ ),
349
+ ) -> None:
350
+ """Export an assessment report in the given format."""
351
+ client = CCIDClient()
352
+ with console.status(f"[bold cyan]Exporting {format}...[/bold cyan]"):
353
+ content = client.export(assessment_id=assessment_id, format=format)
354
+
355
+ if output:
356
+ Path(output).write_text(content)
357
+ console.print(f"[green]Written to {output}[/green]")
358
+ else:
359
+ if format in ("json", "sarif"):
360
+ try:
361
+ console.print_json(content)
362
+ except Exception:
363
+ console.print(content)
364
+ else:
365
+ console.print(content)
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # ccid status
370
+ # ---------------------------------------------------------------------------
371
+
372
+ @app.command()
373
+ def status() -> None:
374
+ """Check API health."""
375
+ client = CCIDClient()
376
+ try:
377
+ data = client.health()
378
+ except Exception as exc:
379
+ console.print(f"[red]API unreachable: {exc}[/red]")
380
+ raise typer.Exit(code=1)
381
+
382
+ health = data.get("status", "unknown")
383
+ style = "green" if health == "healthy" else "red"
384
+ db_status = data.get("database", "unknown")
385
+ redis_status = "connected" if data.get("redis") else "unavailable"
386
+ env = data.get("environment", "unknown")
387
+
388
+ tbl = Table(title="CCID API Status", show_lines=True)
389
+ tbl.add_column("Component", style="bold")
390
+ tbl.add_column("Status")
391
+ tbl.add_row("API", Text(health.upper(), style=style))
392
+ tbl.add_row("Database", db_status)
393
+ tbl.add_row("Redis", redis_status)
394
+ tbl.add_row("Environment", env)
395
+ console.print(tbl)
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # ccid replay
400
+ # ---------------------------------------------------------------------------
401
+
402
+ @app.command()
403
+ def replay(
404
+ assessment_id: str = typer.Argument(..., help="Assessment UUID to replay"),
405
+ ) -> None:
406
+ """Replay the swarm reasoning chain for an assessment.
407
+
408
+ Fetches swarm-results from the API and renders each node's
409
+ reasoning as sequential panels in the terminal.
410
+ """
411
+ client = CCIDClient()
412
+ with console.status("[bold cyan]Fetching swarm results...[/bold cyan]"):
413
+ try:
414
+ data = client.swarm_results(assessment_id)
415
+ except Exception as exc:
416
+ console.print(f"[red]Failed to fetch swarm results: {exc}[/red]")
417
+ raise typer.Exit(code=1)
418
+
419
+ format_swarm_replay(data)
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # ccid patterns
424
+ # ---------------------------------------------------------------------------
425
+
426
+ @app.command()
427
+ def patterns(
428
+ control: Optional[str] = typer.Option(
429
+ None, "--control", "-c", help="Find patterns for a specific control ID (e.g. AC-2, SC-28)"
430
+ ),
431
+ framework: Optional[str] = typer.Option(
432
+ None, "--framework", "-f", help="Framework code (e.g. NIST_800_53_R5)"
433
+ ),
434
+ list_all: bool = typer.Option(
435
+ False, "--list", "-l", help="List all golden patterns"
436
+ ),
437
+ registries: bool = typer.Option(
438
+ False, "--registries", "-r", help="List configured artifact registries"
439
+ ),
440
+ sync: Optional[str] = typer.Option(
441
+ None, "--sync", help="Trigger sync for a registry ID"
442
+ ),
443
+ format: str = typer.Option(
444
+ "text", "--format", help="Output format: json, text"
445
+ ),
446
+ ) -> None:
447
+ """Manage golden patterns, artifact registries, and pattern recommendations.
448
+
449
+ Examples:
450
+ ccid patterns --control SC-28 --framework NIST_800_53_R5
451
+ ccid patterns --list
452
+ ccid patterns --registries
453
+ ccid patterns --sync <registry_id>
454
+ """
455
+ client = CCIDClient()
456
+
457
+ if sync:
458
+ with console.status(f"[bold cyan]Syncing registry {sync}...[/bold cyan]"):
459
+ try:
460
+ data = client.post(f"/api/artifact-registries/{sync}/sync")
461
+ except Exception as exc:
462
+ console.print(f"[red]Sync failed: {exc}[/red]")
463
+ raise typer.Exit(code=1)
464
+ if format == "json":
465
+ console.print_json(data=data)
466
+ else:
467
+ console.print(Panel(
468
+ f"Patterns found: {data.get('patterns_found', 0)}\n"
469
+ f"New: {data.get('new', 0)}\n"
470
+ f"Updated: {data.get('updated', 0)}\n"
471
+ f"Errors: {data.get('errors', 0)}",
472
+ title="Registry Sync Results",
473
+ ))
474
+ return
475
+
476
+ if registries:
477
+ with console.status("[bold cyan]Fetching registries...[/bold cyan]"):
478
+ try:
479
+ data = client.get("/api/artifact-registries")
480
+ except Exception as exc:
481
+ console.print(f"[red]Failed: {exc}[/red]")
482
+ raise typer.Exit(code=1)
483
+ if format == "json":
484
+ console.print_json(data=data)
485
+ else:
486
+ tbl = Table(title="Artifact Registries", show_lines=True)
487
+ tbl.add_column("Name", style="bold")
488
+ tbl.add_column("Type")
489
+ tbl.add_column("Status")
490
+ tbl.add_column("Last Sync")
491
+ for r in data.get("registries", []):
492
+ status_style = "green" if r.get("status") == "active" else "red"
493
+ tbl.add_row(
494
+ r.get("name", ""),
495
+ r.get("registry_type", ""),
496
+ Text(r.get("status", ""), style=status_style),
497
+ r.get("last_sync_at", "never"),
498
+ )
499
+ console.print(tbl)
500
+ return
501
+
502
+ if list_all:
503
+ with console.status("[bold cyan]Fetching patterns...[/bold cyan]"):
504
+ try:
505
+ data = client.get("/api/patterns")
506
+ except Exception as exc:
507
+ console.print(f"[red]Failed: {exc}[/red]")
508
+ raise typer.Exit(code=1)
509
+ if format == "json":
510
+ console.print_json(data=data)
511
+ else:
512
+ tbl = Table(title="Golden Patterns", show_lines=True)
513
+ tbl.add_column("Name", style="bold")
514
+ tbl.add_column("Type")
515
+ tbl.add_column("Status")
516
+ tbl.add_column("Controls")
517
+ for p in data.get("patterns", []):
518
+ tbl.add_row(
519
+ p.get("name", ""),
520
+ p.get("pattern_type", ""),
521
+ p.get("analysis_status", ""),
522
+ str(p.get("control_count", 0)),
523
+ )
524
+ console.print(tbl)
525
+ return
526
+
527
+ if control and framework:
528
+ with console.status(f"[bold cyan]Searching patterns for {framework}/{control}...[/bold cyan]"):
529
+ try:
530
+ data = client.get(f"/api/patterns/search?framework={framework}&control_id={control}")
531
+ except Exception as exc:
532
+ console.print(f"[red]Failed: {exc}[/red]")
533
+ raise typer.Exit(code=1)
534
+ if format == "json":
535
+ console.print_json(data=data)
536
+ else:
537
+ format_pattern_recommendations(data.get("patterns", []), framework, control)
538
+ return
539
+
540
+ console.print("[yellow]Use --control + --framework, --list, --registries, or --sync[/yellow]")
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # ccid review-queue
545
+ # ---------------------------------------------------------------------------
546
+
547
+ @app.command(name="review-queue")
548
+ def review_queue(
549
+ target_type: Optional[str] = typer.Option(
550
+ None, "--type", "-t", help="Filter by type: framework_control, synergy, pattern_mapping, pattern_validation"
551
+ ),
552
+ priority: Optional[str] = typer.Option(
553
+ None, "--priority", "-p", help="Filter by priority: critical, high, medium, low"
554
+ ),
555
+ populate: bool = typer.Option(
556
+ False, "--populate", help="Auto-populate queue from low-confidence items"
557
+ ),
558
+ threshold: float = typer.Option(
559
+ 0.70, "--threshold", help="Confidence threshold for auto-populate"
560
+ ),
561
+ stats: bool = typer.Option(
562
+ False, "--stats", help="Show queue statistics"
563
+ ),
564
+ format: str = typer.Option(
565
+ "text", "--format", help="Output format: json, text"
566
+ ),
567
+ ) -> None:
568
+ """Manage the review queue — view pending items, populate, and stats.
569
+
570
+ Examples:
571
+ ccid review-queue Show pending items
572
+ ccid review-queue --type framework_control Filter by type
573
+ ccid review-queue --populate Auto-populate from low-confidence
574
+ ccid review-queue --stats Queue statistics
575
+ """
576
+ client = CCIDClient()
577
+
578
+ if populate:
579
+ with console.status("[bold cyan]Populating review queue...[/bold cyan]"):
580
+ try:
581
+ data = client.post(f"/api/review-queue/populate?threshold={threshold}")
582
+ except Exception as exc:
583
+ console.print(f"[red]Populate failed: {exc}[/red]")
584
+ raise typer.Exit(code=1)
585
+ if format == "json":
586
+ console.print_json(data=data)
587
+ else:
588
+ console.print(Panel(
589
+ f"Items added: {data.get('items_added', 0)}\nThreshold: {data.get('threshold', threshold)}",
590
+ title="Review Queue Populated",
591
+ ))
592
+ return
593
+
594
+ if stats:
595
+ with console.status("[bold cyan]Fetching queue stats...[/bold cyan]"):
596
+ try:
597
+ data = client.get("/api/review-queue/stats")
598
+ except Exception as exc:
599
+ console.print(f"[red]Failed: {exc}[/red]")
600
+ raise typer.Exit(code=1)
601
+ if format == "json":
602
+ console.print_json(data=data)
603
+ else:
604
+ from ccid_cli.formatters import format_review_queue_stats
605
+ format_review_queue_stats(data)
606
+ return
607
+
608
+ # Default: show pending items
609
+ url = "/api/reviews/queue?"
610
+ if target_type:
611
+ url += f"target_type={target_type}&"
612
+ if priority:
613
+ url += f"priority={priority}&"
614
+ url += "limit=50"
615
+
616
+ with console.status("[bold cyan]Fetching review queue...[/bold cyan]"):
617
+ try:
618
+ data = client.get(url)
619
+ except Exception as exc:
620
+ console.print(f"[red]Failed: {exc}[/red]")
621
+ raise typer.Exit(code=1)
622
+
623
+ if format == "json":
624
+ console.print_json(data=data)
625
+ else:
626
+ from ccid_cli.formatters import format_review_queue
627
+ format_review_queue(data.get("items", []))
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # ccid validate-pattern
632
+ # ---------------------------------------------------------------------------
633
+
634
+ @app.command(name="validate-pattern")
635
+ def validate_pattern(
636
+ pattern_id: str = typer.Argument(..., help="UUID of the golden pattern to validate"),
637
+ claim_type: str = typer.Option(
638
+ "framework_control", "--type", "-t",
639
+ help="Claim type: framework_control, policy_requirement, obligation"
640
+ ),
641
+ reference_id: str = typer.Option(
642
+ ..., "--reference", "-r", help="UUID of the reference entity"
643
+ ),
644
+ description: str = typer.Option(
645
+ "", "--description", "-d", help="Description of the claim"
646
+ ),
647
+ format: str = typer.Option(
648
+ "text", "--format", help="Output format: json, text"
649
+ ),
650
+ ) -> None:
651
+ """Validate a golden pattern against a claimed requirement.
652
+
653
+ Examples:
654
+ ccid validate-pattern <id> --type framework_control --reference <control-uuid>
655
+ """
656
+ client = CCIDClient()
657
+ claims = [{
658
+ "type": claim_type,
659
+ "reference_id": reference_id,
660
+ "claim_description": description,
661
+ }]
662
+
663
+ with console.status("[bold cyan]Validating pattern...[/bold cyan]"):
664
+ try:
665
+ data = client.post(f"/api/patterns/{pattern_id}/validate", json={"claims": claims})
666
+ except Exception as exc:
667
+ console.print(f"[red]Validation failed: {exc}[/red]")
668
+ raise typer.Exit(code=1)
669
+
670
+ if format == "json":
671
+ console.print_json(data=data)
672
+ else:
673
+ from ccid_cli.formatters import format_pattern_validation
674
+ format_pattern_validation(data)
675
+
676
+
677
+ # ---------------------------------------------------------------------------
678
+ # Helpers
679
+ # ---------------------------------------------------------------------------
680
+
681
+ def _exit_for_gate(gate: str, fail_on: str | None) -> None:
682
+ """Raise typer.Exit with the appropriate code based on gate decision.
683
+
684
+ Exit codes:
685
+ 0 = pass
686
+ 1 = block
687
+ 2 = warn
688
+
689
+ --fail-on controls which gates cause non-zero exit:
690
+ "block" → only exit 1 on block
691
+ "warn" → exit 2 on warn, exit 1 on block
692
+ None/""/none/off → always exit 0 (informational only)
693
+ """
694
+ if fail_on is None or fail_on.lower() in ("", "none", "off"):
695
+ return
696
+
697
+ if gate == "block":
698
+ raise typer.Exit(code=1)
699
+
700
+ if gate == "warn" and fail_on == "warn":
701
+ raise typer.Exit(code=2)
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # Entry point
706
+ # ---------------------------------------------------------------------------
707
+
708
+ if __name__ == "__main__":
709
+ app()