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/__init__.py +4 -0
- ccid_cli/api.py +169 -0
- ccid_cli/formatters.py +480 -0
- ccid_cli/main.py +709 -0
- ccid_cli-0.1.1.dist-info/METADATA +9 -0
- ccid_cli-0.1.1.dist-info/RECORD +9 -0
- ccid_cli-0.1.1.dist-info/WHEEL +5 -0
- ccid_cli-0.1.1.dist-info/entry_points.txt +2 -0
- ccid_cli-0.1.1.dist-info/top_level.txt +1 -0
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()
|