ccid-cli 0.1.1__tar.gz
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-0.1.1/PKG-INFO +9 -0
- ccid_cli-0.1.1/ccid_cli/__init__.py +4 -0
- ccid_cli-0.1.1/ccid_cli/api.py +169 -0
- ccid_cli-0.1.1/ccid_cli/formatters.py +480 -0
- ccid_cli-0.1.1/ccid_cli/main.py +709 -0
- ccid_cli-0.1.1/ccid_cli.egg-info/PKG-INFO +9 -0
- ccid_cli-0.1.1/ccid_cli.egg-info/SOURCES.txt +11 -0
- ccid_cli-0.1.1/ccid_cli.egg-info/dependency_links.txt +1 -0
- ccid_cli-0.1.1/ccid_cli.egg-info/entry_points.txt +2 -0
- ccid_cli-0.1.1/ccid_cli.egg-info/requires.txt +4 -0
- ccid_cli-0.1.1/ccid_cli.egg-info/top_level.txt +1 -0
- ccid_cli-0.1.1/pyproject.toml +16 -0
- ccid_cli-0.1.1/setup.cfg +4 -0
ccid_cli-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ccid-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CCID Compliance CLI - Pipeline-native compliance checking
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: typer>=0.9.0
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: websockets>=12.0
|
|
@@ -0,0 +1,169 @@
|
|
|
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 API Client — httpx-based client for the CCID backend API.
|
|
7
|
+
|
|
8
|
+
Reads configuration from ~/.ccid/config.json and provides typed methods
|
|
9
|
+
for every endpoint the CLI needs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
CONFIG_PATH = Path.home() / ".ccid" / "config.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config() -> dict[str, str]:
|
|
24
|
+
"""Load saved configuration from ~/.ccid/config.json."""
|
|
25
|
+
if CONFIG_PATH.exists():
|
|
26
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save_config(data: dict[str, str]) -> None:
|
|
31
|
+
"""Persist configuration to ~/.ccid/config.json."""
|
|
32
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
CONFIG_PATH.write_text(json.dumps(data, indent=2))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CCIDClient:
|
|
37
|
+
"""Thin wrapper around the CCID REST API."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
base_url: str | None = None,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
cfg = load_config()
|
|
45
|
+
self.base_url = (base_url or cfg.get("api_url", "")).rstrip("/")
|
|
46
|
+
self.api_key = api_key or cfg.get("api_key", "")
|
|
47
|
+
if not self.base_url:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
"No API URL configured. Run `ccid configure` first."
|
|
50
|
+
)
|
|
51
|
+
# `ccid analyze` blocks on a full synchronous code-analysis swarm —
|
|
52
|
+
# ~6 min even for a single resource (TRU-583: measured 376s), and the
|
|
53
|
+
# backend's Cloud Run request timeout is 3600s. A 120s client timeout
|
|
54
|
+
# made every sync analyze fail with "read operation timed out" before
|
|
55
|
+
# the swarm returned. 900s covers realistic analyses; use `--live`
|
|
56
|
+
# (WebSocket streaming) for very large repos that exceed it.
|
|
57
|
+
self._client = httpx.Client(
|
|
58
|
+
base_url=self.base_url,
|
|
59
|
+
headers=self._headers(),
|
|
60
|
+
timeout=900.0,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _headers(self) -> dict[str, str]:
|
|
64
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
65
|
+
if self.api_key:
|
|
66
|
+
# CCID API keys authenticate via X-API-Key ONLY. The
|
|
67
|
+
# Authorization: Bearer path is reserved for platform JWTs /
|
|
68
|
+
# Google ID tokens, so a ccid_ key sent there 401s (TRU-583).
|
|
69
|
+
h["X-API-Key"] = self.api_key
|
|
70
|
+
return h
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# WebSocket helper
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def ws_url(self) -> str:
|
|
78
|
+
"""Convert the HTTP(S) base_url to a WebSocket URL."""
|
|
79
|
+
url = self.base_url
|
|
80
|
+
if url.startswith("https://"):
|
|
81
|
+
return url.replace("https://", "wss://", 1) + "/ws"
|
|
82
|
+
if url.startswith("http://"):
|
|
83
|
+
return url.replace("http://", "ws://", 1) + "/ws"
|
|
84
|
+
return "ws://" + url + "/ws"
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Endpoints
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def analyze(
|
|
91
|
+
self,
|
|
92
|
+
code: str,
|
|
93
|
+
frameworks: list[str] | None = None,
|
|
94
|
+
format: str = "json",
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""POST /api/agents/run-code-analysis — trigger a code-analysis swarm."""
|
|
97
|
+
payload: dict[str, Any] = {
|
|
98
|
+
"terraform_code": code,
|
|
99
|
+
"trigger_source": "cli",
|
|
100
|
+
}
|
|
101
|
+
if frameworks:
|
|
102
|
+
payload["frameworks"] = frameworks
|
|
103
|
+
resp = self._client.post("/api/agents/run-code-analysis", json=payload)
|
|
104
|
+
resp.raise_for_status()
|
|
105
|
+
return resp.json()
|
|
106
|
+
|
|
107
|
+
def posture(
|
|
108
|
+
self,
|
|
109
|
+
scope: str = "system",
|
|
110
|
+
scope_id: str | None = None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""GET /api/posture — compliance posture snapshot."""
|
|
113
|
+
params: dict[str, str] = {"scope": scope}
|
|
114
|
+
if scope_id:
|
|
115
|
+
params["scope_id"] = scope_id
|
|
116
|
+
resp = self._client.get("/api/posture", params=params)
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
return resp.json()
|
|
119
|
+
|
|
120
|
+
def assignments(
|
|
121
|
+
self,
|
|
122
|
+
severity: str | None = None,
|
|
123
|
+
status: str = "open",
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
"""GET /api/assignments — findings/assignments list."""
|
|
126
|
+
params: dict[str, str] = {"status": status}
|
|
127
|
+
if severity:
|
|
128
|
+
params["severity"] = severity
|
|
129
|
+
resp = self._client.get("/api/assignments", params=params)
|
|
130
|
+
resp.raise_for_status()
|
|
131
|
+
return resp.json()
|
|
132
|
+
|
|
133
|
+
def export(
|
|
134
|
+
self,
|
|
135
|
+
assessment_id: str,
|
|
136
|
+
format: str = "json",
|
|
137
|
+
) -> str:
|
|
138
|
+
"""GET /api/assessments/{id}/export/{format} — download assessment export."""
|
|
139
|
+
resp = self._client.get(
|
|
140
|
+
f"/api/assessments/{assessment_id}/export/{format}"
|
|
141
|
+
)
|
|
142
|
+
resp.raise_for_status()
|
|
143
|
+
return resp.text
|
|
144
|
+
|
|
145
|
+
def health(self) -> dict[str, Any]:
|
|
146
|
+
"""GET /api/health — backend health check."""
|
|
147
|
+
resp = self._client.get("/api/health")
|
|
148
|
+
resp.raise_for_status()
|
|
149
|
+
return resp.json()
|
|
150
|
+
|
|
151
|
+
def swarm_results(self, assessment_id: str) -> dict[str, Any]:
|
|
152
|
+
"""GET /api/assessments/{id}/swarm-results — swarm execution trace."""
|
|
153
|
+
resp = self._client.get(
|
|
154
|
+
f"/api/assessments/{assessment_id}/swarm-results"
|
|
155
|
+
)
|
|
156
|
+
resp.raise_for_status()
|
|
157
|
+
return resp.json()
|
|
158
|
+
|
|
159
|
+
def get(self, path: str, params: dict | None = None) -> dict[str, Any]:
|
|
160
|
+
"""Generic GET request."""
|
|
161
|
+
resp = self._client.get(path, params=params)
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
return resp.json()
|
|
164
|
+
|
|
165
|
+
def post(self, path: str, json_data: dict | None = None) -> dict[str, Any]:
|
|
166
|
+
"""Generic POST request."""
|
|
167
|
+
resp = self._client.post(path, json=json_data or {})
|
|
168
|
+
resp.raise_for_status()
|
|
169
|
+
return resp.json()
|
|
@@ -0,0 +1,480 @@
|
|
|
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 Formatters — Rich-based output rendering for CLI commands.
|
|
7
|
+
|
|
8
|
+
Every public function accepts the JSON dict returned by the API and
|
|
9
|
+
prints a Rich renderable to the console.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
# -- Colour mappings -------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
_POSTURE_COLOURS = {
|
|
26
|
+
"pass": "bold green",
|
|
27
|
+
"warn": "bold yellow",
|
|
28
|
+
"block": "bold red",
|
|
29
|
+
"unknown": "dim",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_SEVERITY_COLOURS = {
|
|
33
|
+
"critical": "bold red",
|
|
34
|
+
"high": "red",
|
|
35
|
+
"medium": "yellow",
|
|
36
|
+
"low": "cyan",
|
|
37
|
+
"info": "dim",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_STATUS_ICONS = {
|
|
41
|
+
"completed": "[green]OK[/green]",
|
|
42
|
+
"running": "[yellow]...[/yellow]",
|
|
43
|
+
"failed": "[red]FAIL[/red]",
|
|
44
|
+
"pending": "[dim]--[/dim]",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Posture
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def format_posture(data: dict[str, Any]) -> None:
|
|
53
|
+
"""Render compliance posture as a Rich table + summary panel."""
|
|
54
|
+
posture = data.get("posture", "unknown")
|
|
55
|
+
score = data.get("score", 0)
|
|
56
|
+
summary = data.get("summary", "")
|
|
57
|
+
coverage = data.get("obligation_coverage", {})
|
|
58
|
+
traceability = data.get("traceability", {})
|
|
59
|
+
|
|
60
|
+
# Header panel
|
|
61
|
+
posture_style = _POSTURE_COLOURS.get(posture, "dim")
|
|
62
|
+
header = Text()
|
|
63
|
+
header.append("Posture: ", style="bold")
|
|
64
|
+
header.append(posture.upper(), style=posture_style)
|
|
65
|
+
header.append(f" Score: {score:.0%}", style="bold")
|
|
66
|
+
header.append(f"\n{summary}")
|
|
67
|
+
header.append(
|
|
68
|
+
f"\nObligations: {coverage.get('assessed', 0)}/{coverage.get('total', 0)} assessed"
|
|
69
|
+
f" | Traceability links: {traceability.get('total_links', 0)}"
|
|
70
|
+
)
|
|
71
|
+
console.print(Panel(header, title="Compliance Posture", border_style=posture_style))
|
|
72
|
+
|
|
73
|
+
# Framework breakdown table
|
|
74
|
+
frameworks = data.get("frameworks", [])
|
|
75
|
+
if frameworks:
|
|
76
|
+
tbl = Table(title="Framework Breakdown", show_lines=True)
|
|
77
|
+
tbl.add_column("Framework", style="bold")
|
|
78
|
+
tbl.add_column("Compliant", justify="right")
|
|
79
|
+
tbl.add_column("Total", justify="right")
|
|
80
|
+
tbl.add_column("Critical", justify="right", style="red")
|
|
81
|
+
tbl.add_column("High", justify="right", style="yellow")
|
|
82
|
+
tbl.add_column("Last Assessed")
|
|
83
|
+
for fw in frameworks:
|
|
84
|
+
tbl.add_row(
|
|
85
|
+
fw.get("code", ""),
|
|
86
|
+
str(fw.get("obligations_compliant", 0)),
|
|
87
|
+
str(fw.get("obligations_total", 0)),
|
|
88
|
+
str(fw.get("critical_findings", 0)),
|
|
89
|
+
str(fw.get("high_findings", 0)),
|
|
90
|
+
fw.get("last_assessed", "--") or "--",
|
|
91
|
+
)
|
|
92
|
+
console.print(tbl)
|
|
93
|
+
|
|
94
|
+
# Top findings
|
|
95
|
+
top = data.get("top_findings", [])
|
|
96
|
+
if top:
|
|
97
|
+
tbl = Table(title="Top Findings", show_lines=True)
|
|
98
|
+
tbl.add_column("Severity", justify="center")
|
|
99
|
+
tbl.add_column("Title")
|
|
100
|
+
tbl.add_column("Resource")
|
|
101
|
+
tbl.add_column("Status")
|
|
102
|
+
for f in top:
|
|
103
|
+
sev = f.get("severity", "medium")
|
|
104
|
+
tbl.add_row(
|
|
105
|
+
Text(sev.upper(), style=_SEVERITY_COLOURS.get(sev, "")),
|
|
106
|
+
f.get("title", ""),
|
|
107
|
+
f.get("affected_resource", ""),
|
|
108
|
+
f.get("status", "open"),
|
|
109
|
+
)
|
|
110
|
+
console.print(tbl)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Assignments
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def format_assignments(data: dict[str, Any]) -> None:
|
|
118
|
+
"""Render findings/assignments as a Rich table."""
|
|
119
|
+
items = data.get("items", [])
|
|
120
|
+
total = data.get("total", len(items))
|
|
121
|
+
|
|
122
|
+
tbl = Table(title=f"Assignments ({total} total)", show_lines=True)
|
|
123
|
+
tbl.add_column("Severity", justify="center", width=10)
|
|
124
|
+
tbl.add_column("Title", max_width=40)
|
|
125
|
+
tbl.add_column("Status", width=10)
|
|
126
|
+
tbl.add_column("Framework", width=14)
|
|
127
|
+
tbl.add_column("Resource", max_width=30)
|
|
128
|
+
tbl.add_column("Remediation", max_width=40)
|
|
129
|
+
|
|
130
|
+
for item in items:
|
|
131
|
+
sev = item.get("severity", "medium")
|
|
132
|
+
fw = ""
|
|
133
|
+
if item.get("obligation"):
|
|
134
|
+
fw = item["obligation"].get("framework", "")
|
|
135
|
+
tbl.add_row(
|
|
136
|
+
Text(sev.upper(), style=_SEVERITY_COLOURS.get(sev, "")),
|
|
137
|
+
item.get("title", ""),
|
|
138
|
+
item.get("status", "open"),
|
|
139
|
+
fw,
|
|
140
|
+
item.get("affected_resource", "") or "",
|
|
141
|
+
item.get("remediation", "") or "",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
console.print(tbl)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Analysis result
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def format_analysis_result(data: dict[str, Any]) -> None:
|
|
152
|
+
"""Render a code-analysis response as a panel with gate decision + findings."""
|
|
153
|
+
gate = data.get("gate_decision", "unknown")
|
|
154
|
+
confidence = data.get("confidence_score", 0) or 0
|
|
155
|
+
assessment_id = data.get("id", "")
|
|
156
|
+
findings = data.get("findings", [])
|
|
157
|
+
|
|
158
|
+
gate_style = _POSTURE_COLOURS.get(gate, "dim")
|
|
159
|
+
|
|
160
|
+
# Gate decision panel
|
|
161
|
+
header = Text()
|
|
162
|
+
header.append("Gate: ", style="bold")
|
|
163
|
+
header.append(gate.upper(), style=gate_style)
|
|
164
|
+
header.append(f" Confidence: {confidence:.0%}", style="bold")
|
|
165
|
+
header.append(f"\nAssessment: {assessment_id}")
|
|
166
|
+
header.append(f"\nFindings: {len(findings)}")
|
|
167
|
+
console.print(Panel(header, title="Code Analysis Result", border_style=gate_style))
|
|
168
|
+
|
|
169
|
+
if not findings:
|
|
170
|
+
console.print("[green]No findings - code looks compliant.[/green]")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
tbl = Table(title="Findings", show_lines=True)
|
|
174
|
+
tbl.add_column("Severity", justify="center", width=10)
|
|
175
|
+
tbl.add_column("Title", max_width=40)
|
|
176
|
+
tbl.add_column("Resource", max_width=30)
|
|
177
|
+
tbl.add_column("Remediation", max_width=50)
|
|
178
|
+
|
|
179
|
+
for f in findings:
|
|
180
|
+
sev = f.get("severity", "medium")
|
|
181
|
+
tbl.add_row(
|
|
182
|
+
Text(sev.upper(), style=_SEVERITY_COLOURS.get(sev, "")),
|
|
183
|
+
f.get("title", ""),
|
|
184
|
+
f.get("affected_resource", "") or "",
|
|
185
|
+
f.get("remediation", "") or "",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
console.print(tbl)
|
|
189
|
+
|
|
190
|
+
# Show remediation code blocks if present
|
|
191
|
+
for f in findings:
|
|
192
|
+
if f.get("remediation_code"):
|
|
193
|
+
console.print(
|
|
194
|
+
Panel(
|
|
195
|
+
f["remediation_code"],
|
|
196
|
+
title=f"Fix: {f.get('title', '')}",
|
|
197
|
+
border_style="green",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Show pattern recommendations if present
|
|
202
|
+
recs = data.get("pattern_recommendations", [])
|
|
203
|
+
if recs:
|
|
204
|
+
format_pattern_recommendations(recs)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Swarm replay
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def format_swarm_replay(data: dict[str, Any]) -> None:
|
|
212
|
+
"""Render swarm execution nodes as sequential reasoning panels with timing."""
|
|
213
|
+
execution = data.get("execution", {})
|
|
214
|
+
nodes = data.get("nodes", [])
|
|
215
|
+
|
|
216
|
+
# Execution summary header
|
|
217
|
+
swarm_type = execution.get("swarm_type", "unknown")
|
|
218
|
+
status = execution.get("status", "unknown")
|
|
219
|
+
total_tokens = execution.get("total_llm_tokens", 0)
|
|
220
|
+
total_duration = execution.get("total_duration_ms", 0)
|
|
221
|
+
total_nodes = execution.get("total_nodes", 0)
|
|
222
|
+
completed_nodes = execution.get("completed_nodes", 0)
|
|
223
|
+
|
|
224
|
+
header = Text()
|
|
225
|
+
header.append("Swarm: ", style="bold")
|
|
226
|
+
header.append(swarm_type, style="cyan bold")
|
|
227
|
+
header.append(f" Status: ", style="bold")
|
|
228
|
+
header.append(status.upper(), style=_POSTURE_COLOURS.get(status, "dim"))
|
|
229
|
+
header.append(f"\nNodes: {completed_nodes}/{total_nodes}")
|
|
230
|
+
header.append(f" Tokens: {total_tokens:,}")
|
|
231
|
+
if total_duration:
|
|
232
|
+
header.append(f" Duration: {total_duration / 1000:.1f}s")
|
|
233
|
+
console.print(
|
|
234
|
+
Panel(header, title="Swarm Execution Replay", border_style="cyan")
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if not nodes:
|
|
238
|
+
console.print("[dim]No node results to replay.[/dim]")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
for i, node in enumerate(nodes, 1):
|
|
242
|
+
name = node.get("name", f"node-{i}")
|
|
243
|
+
node_status = node.get("status", "unknown")
|
|
244
|
+
reasoning = node.get("reasoning", "")
|
|
245
|
+
tokens = node.get("llm_tokens_used", 0)
|
|
246
|
+
duration = node.get("duration_ms", 0)
|
|
247
|
+
|
|
248
|
+
# Status indicator
|
|
249
|
+
status_indicator = _STATUS_ICONS.get(node_status, f"[dim]{node_status}[/dim]")
|
|
250
|
+
|
|
251
|
+
# Build subtitle with timing
|
|
252
|
+
subtitle_parts = [f"{status_indicator}"]
|
|
253
|
+
if tokens:
|
|
254
|
+
subtitle_parts.append(f"{tokens:,} tokens")
|
|
255
|
+
if duration:
|
|
256
|
+
subtitle_parts.append(f"{duration / 1000:.1f}s")
|
|
257
|
+
subtitle = " | ".join(subtitle_parts)
|
|
258
|
+
|
|
259
|
+
# Reasoning content
|
|
260
|
+
content = Text()
|
|
261
|
+
if reasoning:
|
|
262
|
+
# Truncate very long reasoning but keep it readable
|
|
263
|
+
display_reasoning = reasoning if len(reasoning) <= 1000 else reasoning[:997] + "..."
|
|
264
|
+
content.append(display_reasoning)
|
|
265
|
+
else:
|
|
266
|
+
content.append("(no reasoning captured)", style="dim")
|
|
267
|
+
|
|
268
|
+
# Output data summary if present
|
|
269
|
+
output = node.get("output_data")
|
|
270
|
+
if output and isinstance(output, dict):
|
|
271
|
+
content.append("\n\nOutput keys: ", style="bold")
|
|
272
|
+
content.append(", ".join(output.keys()), style="dim")
|
|
273
|
+
|
|
274
|
+
border = "green" if node_status == "completed" else "red" if node_status == "failed" else "yellow"
|
|
275
|
+
console.print(
|
|
276
|
+
Panel(
|
|
277
|
+
content,
|
|
278
|
+
title=f"[{i}/{len(nodes)}] {name}",
|
|
279
|
+
subtitle=subtitle,
|
|
280
|
+
border_style=border,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# Pattern Recommendations
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def format_pattern_recommendations(
|
|
290
|
+
patterns: list[dict[str, Any]],
|
|
291
|
+
framework: str | None = None,
|
|
292
|
+
control: str | None = None,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Render pattern recommendations as Rich panels with reasoning chains."""
|
|
295
|
+
if not patterns:
|
|
296
|
+
console.print("[dim]No matching golden patterns found.[/dim]")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
title = "Golden Pattern Recommendations"
|
|
300
|
+
if framework and control:
|
|
301
|
+
title += f" for {framework}/{control}"
|
|
302
|
+
|
|
303
|
+
tbl = Table(title=title, show_lines=True)
|
|
304
|
+
tbl.add_column("Pattern", style="bold", max_width=30)
|
|
305
|
+
tbl.add_column("Type", width=12)
|
|
306
|
+
tbl.add_column("Satisfaction", justify="center", width=14)
|
|
307
|
+
tbl.add_column("Confidence", justify="right", width=12)
|
|
308
|
+
tbl.add_column("Controls", max_width=30)
|
|
309
|
+
|
|
310
|
+
for p in patterns:
|
|
311
|
+
# Handle both search results and recommendation results
|
|
312
|
+
name = p.get("pattern_name", p.get("name", ""))
|
|
313
|
+
ptype = p.get("pattern_type", "")
|
|
314
|
+
sat = p.get("satisfaction_level", "")
|
|
315
|
+
conf = p.get("confidence", 0)
|
|
316
|
+
|
|
317
|
+
controls = p.get("controls_satisfied", [])
|
|
318
|
+
ctrl_str = ", ".join(c.get("control_id", "") for c in controls[:4]) if controls else ""
|
|
319
|
+
|
|
320
|
+
conf_style = "green" if conf >= 0.8 else "yellow" if conf >= 0.5 else "red"
|
|
321
|
+
tbl.add_row(
|
|
322
|
+
name, ptype, sat,
|
|
323
|
+
Text(f"{conf:.0%}" if conf else "", style=conf_style),
|
|
324
|
+
ctrl_str,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
console.print(tbl)
|
|
328
|
+
|
|
329
|
+
# Show reasoning chains for top recommendations
|
|
330
|
+
for p in patterns[:3]:
|
|
331
|
+
chain = p.get("reasoning_chain", {})
|
|
332
|
+
if not chain:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
steps = chain.get("steps", [])
|
|
336
|
+
if not steps:
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
content = Text()
|
|
340
|
+
for step in steps[:5]:
|
|
341
|
+
content.append(f"\n{step.get('step', '?')}. [{step.get('phase', '')}] ", style="bold")
|
|
342
|
+
content.append(step.get("reasoning", "")[:200])
|
|
343
|
+
content.append(f"\n → {step.get('output', '')[:150]}", style="dim")
|
|
344
|
+
|
|
345
|
+
conclusion = chain.get("final_conclusion", "")
|
|
346
|
+
if conclusion:
|
|
347
|
+
content.append(f"\n\nConclusion: ", style="bold green")
|
|
348
|
+
content.append(conclusion[:200])
|
|
349
|
+
|
|
350
|
+
console.print(Panel(
|
|
351
|
+
content,
|
|
352
|
+
title=f"Reasoning: {p.get('pattern_name', p.get('name', ''))}",
|
|
353
|
+
border_style="cyan",
|
|
354
|
+
))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# Review Queue
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
_PRIORITY_COLOURS = {
|
|
362
|
+
"critical": "bold red",
|
|
363
|
+
"high": "red",
|
|
364
|
+
"medium": "yellow",
|
|
365
|
+
"low": "cyan",
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def format_review_queue(items: list[dict[str, Any]]) -> None:
|
|
370
|
+
"""Render pending review queue items as a Rich table."""
|
|
371
|
+
if not items:
|
|
372
|
+
console.print("[green]Review queue is empty — nothing pending.[/green]")
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
tbl = Table(title=f"Review Queue ({len(items)} pending)", show_lines=True)
|
|
376
|
+
tbl.add_column("Priority", justify="center", width=10)
|
|
377
|
+
tbl.add_column("Type", width=18)
|
|
378
|
+
tbl.add_column("Control", width=14)
|
|
379
|
+
tbl.add_column("Reason", max_width=25)
|
|
380
|
+
tbl.add_column("Status", width=12)
|
|
381
|
+
tbl.add_column("Assigned To", max_width=20)
|
|
382
|
+
tbl.add_column("Created", width=16)
|
|
383
|
+
|
|
384
|
+
for item in items:
|
|
385
|
+
p = item.get("priority", "medium")
|
|
386
|
+
tbl.add_row(
|
|
387
|
+
Text(p.upper(), style=_PRIORITY_COLOURS.get(p, "")),
|
|
388
|
+
item.get("target_type", ""),
|
|
389
|
+
item.get("control_id", "") or "",
|
|
390
|
+
item.get("queue_reason", ""),
|
|
391
|
+
item.get("status", "pending"),
|
|
392
|
+
item.get("assigned_to", "") or "--",
|
|
393
|
+
(item.get("created_at", "") or "")[:16],
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
console.print(tbl)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def format_review_queue_stats(data: dict[str, Any]) -> None:
|
|
400
|
+
"""Render review queue statistics."""
|
|
401
|
+
total = data.get("total", 0)
|
|
402
|
+
by_status = data.get("by_status", {})
|
|
403
|
+
by_target = data.get("by_target_type", {})
|
|
404
|
+
by_priority = data.get("by_priority", {})
|
|
405
|
+
avg_time = data.get("avg_time_to_review_hours")
|
|
406
|
+
|
|
407
|
+
header = Text()
|
|
408
|
+
header.append(f"Total items: {total}\n", style="bold")
|
|
409
|
+
if avg_time is not None:
|
|
410
|
+
header.append(f"Avg time to review: {avg_time:.1f} hours\n")
|
|
411
|
+
|
|
412
|
+
header.append("\nBy Status: ", style="bold")
|
|
413
|
+
for s, c in by_status.items():
|
|
414
|
+
header.append(f" {s}: {c}")
|
|
415
|
+
|
|
416
|
+
header.append("\nBy Priority: ", style="bold")
|
|
417
|
+
for p, c in sorted(by_priority.items()):
|
|
418
|
+
header.append(f" {p}: {c}")
|
|
419
|
+
|
|
420
|
+
header.append("\nBy Type: ", style="bold")
|
|
421
|
+
for t, c in by_target.items():
|
|
422
|
+
header.append(f" {t}: {c}")
|
|
423
|
+
|
|
424
|
+
console.print(Panel(header, title="Review Queue Statistics"))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# Pattern Validation
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
_VALIDATION_COLOURS = {
|
|
432
|
+
"passed": "bold green",
|
|
433
|
+
"partial": "yellow",
|
|
434
|
+
"failed": "bold red",
|
|
435
|
+
"needs_policy": "magenta",
|
|
436
|
+
"pending": "dim",
|
|
437
|
+
"validating": "cyan",
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def format_pattern_validation(data: dict[str, Any]) -> None:
|
|
442
|
+
"""Render pattern validation results."""
|
|
443
|
+
pattern_name = data.get("pattern_name", "Unknown")
|
|
444
|
+
status = data.get("validation_status", "unknown")
|
|
445
|
+
summary = data.get("summary", {})
|
|
446
|
+
|
|
447
|
+
status_style = _VALIDATION_COLOURS.get(status, "dim")
|
|
448
|
+
header = Text()
|
|
449
|
+
header.append("Pattern: ", style="bold")
|
|
450
|
+
header.append(pattern_name, style="cyan")
|
|
451
|
+
header.append(f"\nValidation: ", style="bold")
|
|
452
|
+
header.append(status.upper(), style=status_style)
|
|
453
|
+
header.append(f"\nClaims: {summary.get('total_claims', 0)}")
|
|
454
|
+
header.append(f" Passed: {summary.get('passed', 0)}")
|
|
455
|
+
header.append(f" Failed: {summary.get('failed', 0)}")
|
|
456
|
+
if summary.get("needs_policy", 0):
|
|
457
|
+
header.append(f" Needs Policy: {summary.get('needs_policy', 0)}", style="magenta")
|
|
458
|
+
|
|
459
|
+
console.print(Panel(header, title="Pattern Validation Result", border_style=status_style))
|
|
460
|
+
|
|
461
|
+
# Detail per validation
|
|
462
|
+
validations = data.get("validations", [])
|
|
463
|
+
for v in validations:
|
|
464
|
+
v_status = v.get("overall_status", "unknown")
|
|
465
|
+
v_style = _VALIDATION_COLOURS.get(v_status, "dim")
|
|
466
|
+
|
|
467
|
+
content = Text()
|
|
468
|
+
content.append(f"Type: {v.get('claim_type', '')}\n")
|
|
469
|
+
content.append(f"Claim: {v.get('claim_description', '')}\n")
|
|
470
|
+
content.append(f"Status: ", style="bold")
|
|
471
|
+
content.append(v_status.upper(), style=v_style)
|
|
472
|
+
content.append(f" Confidence: {v.get('confidence', 0):.0%}\n")
|
|
473
|
+
|
|
474
|
+
steps = v.get("steps", [])
|
|
475
|
+
for s in steps:
|
|
476
|
+
step_status = s.get("status", "")
|
|
477
|
+
icon = {"passed": "[green]PASS[/green]", "failed": "[red]FAIL[/red]", "warning": "[yellow]WARN[/yellow]"}.get(step_status, step_status)
|
|
478
|
+
content.append(f"\n Step {s.get('step', '?')}: [{s.get('type', '')}] {icon}")
|
|
479
|
+
|
|
480
|
+
console.print(Panel(content, border_style=v_style))
|