bitcraft 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bitcraft/__init__.py +3 -0
- bitcraft/api_client.py +146 -0
- bitcraft/app.py +217 -0
- bitcraft/assets/mascots/angry.png +0 -0
- bitcraft/assets/mascots/confused.png +0 -0
- bitcraft/assets/mascots/happy.png +0 -0
- bitcraft/assets/mascots/neutral.png +0 -0
- bitcraft/assets/mascots/panic.png +0 -0
- bitcraft/assets/mascots/shocked.png +0 -0
- bitcraft/assets/mascots/sleepy.png +0 -0
- bitcraft/assets/mascots/thinking.png +0 -0
- bitcraft/cli.py +132 -0
- bitcraft/helpers/__init__.py +1 -0
- bitcraft/helpers/drivers.py +37 -0
- bitcraft/helpers/format.py +43 -0
- bitcraft/helpers/severity.py +32 -0
- bitcraft/helpers/stub_chart.py +133 -0
- bitcraft/launcher.py +198 -0
- bitcraft/providers/__init__.py +1 -0
- bitcraft/providers/api_provider.py +400 -0
- bitcraft/providers/demo_provider.py +597 -0
- bitcraft/providers/factory.py +92 -0
- bitcraft/providers/models.py +298 -0
- bitcraft/providers/provider.py +82 -0
- bitcraft/screens/__init__.py +1 -0
- bitcraft/screens/alert_detail.py +188 -0
- bitcraft/screens/boot.py +254 -0
- bitcraft/screens/dashboard.py +304 -0
- bitcraft/screens/graph_explorer.py +169 -0
- bitcraft/screens/splash.py +47 -0
- bitcraft/screens/threat_detection.py +415 -0
- bitcraft/screens/wallet.py +182 -0
- bitcraft/store.py +100 -0
- bitcraft/theme.tcss +587 -0
- bitcraft/widgets/__init__.py +1 -0
- bitcraft/widgets/alert_detail_panel.py +18 -0
- bitcraft/widgets/alert_list.py +61 -0
- bitcraft/widgets/chart.py +298 -0
- bitcraft/widgets/chart_panel.py +349 -0
- bitcraft/widgets/community_table.py +34 -0
- bitcraft/widgets/filter_panel.py +71 -0
- bitcraft/widgets/graph_view.py +308 -0
- bitcraft/widgets/header_bar.py +86 -0
- bitcraft/widgets/kpi_summary.py +88 -0
- bitcraft/widgets/logo.py +115 -0
- bitcraft/widgets/mascot.py +264 -0
- bitcraft/widgets/panel_state.py +85 -0
- bitcraft/widgets/score_bar.py +27 -0
- bitcraft/widgets/severity_chip.py +38 -0
- bitcraft/widgets/stacked_bar.py +66 -0
- bitcraft-0.1.0.dist-info/METADATA +79 -0
- bitcraft-0.1.0.dist-info/RECORD +57 -0
- bitcraft-0.1.0.dist-info/WHEEL +5 -0
- bitcraft-0.1.0.dist-info/entry_points.txt +2 -0
- bitcraft-0.1.0.dist-info/licenses/LICENSE +202 -0
- bitcraft-0.1.0.dist-info/licenses/NOTICE +28 -0
- bitcraft-0.1.0.dist-info/top_level.txt +1 -0
bitcraft/__init__.py
ADDED
bitcraft/api_client.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""HTTP client for the BitCraft FastAPI backend.
|
|
2
|
+
|
|
3
|
+
Thin transport only: JSON in and out. Parsing into dataclasses lives in
|
|
4
|
+
ApiProvider. All ML results are pre-computed; this client never triggers
|
|
5
|
+
scoring (plans/plan.md section 10.3).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
15
|
+
DEFAULT_TIMEOUT_S = 1.5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiClient:
|
|
19
|
+
"""httpx wrapper around the plan section 10.1 endpoints."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
24
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
25
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.base_url = base_url.rstrip("/")
|
|
28
|
+
self.timeout_s = timeout_s
|
|
29
|
+
self._client = httpx.Client(
|
|
30
|
+
base_url=self.base_url,
|
|
31
|
+
timeout=timeout_s,
|
|
32
|
+
transport=transport,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def close(self) -> None:
|
|
36
|
+
"""Close the underlying httpx client."""
|
|
37
|
+
self._client.close()
|
|
38
|
+
|
|
39
|
+
def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
|
|
40
|
+
"""GET JSON from path. Raises httpx.HTTPError on transport failure."""
|
|
41
|
+
response = self._client.get(path, params=params)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
return response.json()
|
|
44
|
+
|
|
45
|
+
def health(self) -> dict:
|
|
46
|
+
"""GET /health."""
|
|
47
|
+
return self.get("/health")
|
|
48
|
+
|
|
49
|
+
def get_alerts(self, params: Optional[dict[str, Any]] = None) -> Any:
|
|
50
|
+
"""GET /alerts."""
|
|
51
|
+
return self.get("/alerts", params=params)
|
|
52
|
+
|
|
53
|
+
def get_alert_detail(self, tx_id: int) -> dict:
|
|
54
|
+
"""GET /alerts/{tx_id}."""
|
|
55
|
+
return self.get(f"/alerts/{tx_id}")
|
|
56
|
+
|
|
57
|
+
def get_graph(self, tx_id: int, depth: int = 1) -> dict:
|
|
58
|
+
"""GET /graph/{tx_id}?depth=."""
|
|
59
|
+
return self.get(f"/graph/{tx_id}", params={"depth": depth})
|
|
60
|
+
|
|
61
|
+
def get_community(self, community_id: int) -> dict:
|
|
62
|
+
"""GET /communities/{community_id}."""
|
|
63
|
+
return self.get(f"/communities/{community_id}")
|
|
64
|
+
|
|
65
|
+
def get_communities(self, limit: int = 25, sort: str = "risk") -> list:
|
|
66
|
+
"""GET /communities (top communities overview)."""
|
|
67
|
+
return self.get("/communities", params={"limit": limit, "sort": sort})
|
|
68
|
+
|
|
69
|
+
def get_threat_overview(self) -> dict:
|
|
70
|
+
"""GET /threats/overview."""
|
|
71
|
+
return self.get("/threats/overview")
|
|
72
|
+
|
|
73
|
+
def get_entities(self, offset: int = 0, limit: int = 100) -> dict:
|
|
74
|
+
"""GET /entities (ranked wallets)."""
|
|
75
|
+
return self.get("/entities", params={"offset": offset, "limit": limit})
|
|
76
|
+
|
|
77
|
+
def get_entity(self, entity_id: int) -> dict:
|
|
78
|
+
"""GET /entities/{id}."""
|
|
79
|
+
return self.get(f"/entities/{entity_id}")
|
|
80
|
+
|
|
81
|
+
def get_entity_graph(self, entity_id: int) -> dict:
|
|
82
|
+
"""GET /entities/{id}/graph."""
|
|
83
|
+
return self.get(f"/entities/{entity_id}/graph")
|
|
84
|
+
|
|
85
|
+
def get_stats_summary(self) -> dict:
|
|
86
|
+
"""GET /stats/summary."""
|
|
87
|
+
return self.get("/stats/summary")
|
|
88
|
+
|
|
89
|
+
def get_pipeline_status(self) -> dict:
|
|
90
|
+
"""GET /pipeline/status."""
|
|
91
|
+
return self.get("/pipeline/status")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Module-level helpers kept for earlier call sites / tests.
|
|
95
|
+
def get_alerts(params: dict | None = None) -> list[dict]:
|
|
96
|
+
"""GET /alerts via a one-shot client (prefer ApiClient in new code)."""
|
|
97
|
+
client = ApiClient()
|
|
98
|
+
try:
|
|
99
|
+
return client.get_alerts(params)
|
|
100
|
+
finally:
|
|
101
|
+
client.close()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_alert_detail(tx_id: int) -> dict:
|
|
105
|
+
"""GET /alerts/{tx_id} via a one-shot client."""
|
|
106
|
+
client = ApiClient()
|
|
107
|
+
try:
|
|
108
|
+
return client.get_alert_detail(tx_id)
|
|
109
|
+
finally:
|
|
110
|
+
client.close()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_graph(tx_id: int, depth: int = 1) -> dict:
|
|
114
|
+
"""GET /graph/{tx_id} via a one-shot client."""
|
|
115
|
+
client = ApiClient()
|
|
116
|
+
try:
|
|
117
|
+
return client.get_graph(tx_id, depth)
|
|
118
|
+
finally:
|
|
119
|
+
client.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_community(community_id: int) -> dict:
|
|
123
|
+
"""GET /communities/{community_id} via a one-shot client."""
|
|
124
|
+
client = ApiClient()
|
|
125
|
+
try:
|
|
126
|
+
return client.get_community(community_id)
|
|
127
|
+
finally:
|
|
128
|
+
client.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_stats_summary() -> dict:
|
|
132
|
+
"""GET /stats/summary via a one-shot client."""
|
|
133
|
+
client = ApiClient()
|
|
134
|
+
try:
|
|
135
|
+
return client.get_stats_summary()
|
|
136
|
+
finally:
|
|
137
|
+
client.close()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_pipeline_status() -> dict:
|
|
141
|
+
"""GET /pipeline/status via a one-shot client."""
|
|
142
|
+
client = ApiClient()
|
|
143
|
+
try:
|
|
144
|
+
return client.get_pipeline_status()
|
|
145
|
+
finally:
|
|
146
|
+
client.close()
|
bitcraft/app.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""BitCraft TUI entry point.
|
|
2
|
+
|
|
3
|
+
Terminal interface built with Textual. Reads pre-computed alert, graph,
|
|
4
|
+
and community data through a swappable DataProvider (demo or FastAPI).
|
|
5
|
+
No ML code runs here. See docs/tui_design.md and plans/plan.md section 11.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from textual.app import App, ScreenStackError
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.theme import Theme
|
|
17
|
+
|
|
18
|
+
from bitcraft.api_client import DEFAULT_BASE_URL
|
|
19
|
+
from bitcraft.providers.demo_provider import DemoProvider
|
|
20
|
+
from bitcraft.providers.factory import create_provider, resolve_source
|
|
21
|
+
from bitcraft.screens.boot import BootScreen
|
|
22
|
+
from bitcraft.screens.dashboard import DashboardScreen
|
|
23
|
+
from bitcraft.screens.graph_explorer import GraphExplorerScreen
|
|
24
|
+
from bitcraft.screens.splash import SplashScreen
|
|
25
|
+
from bitcraft.screens.threat_detection import ThreatDetectionScreen
|
|
26
|
+
from bitcraft.screens.wallet import WalletsScreen
|
|
27
|
+
from bitcraft.store import Store
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Gold everywhere Textual would otherwise paint its default blue: table
|
|
31
|
+
# cursors, scrollbars, focus borders, footer keys, checkboxes, selections.
|
|
32
|
+
BITCRAFT_THEME = Theme(
|
|
33
|
+
name="bitcraft",
|
|
34
|
+
primary="#d4af37",
|
|
35
|
+
secondary="#ffa700",
|
|
36
|
+
accent="#ffd84d",
|
|
37
|
+
warning="#ff8c42",
|
|
38
|
+
error="#b3261e",
|
|
39
|
+
success="#d4af37",
|
|
40
|
+
foreground="#e6e6e6",
|
|
41
|
+
background="#000000",
|
|
42
|
+
surface="#050505",
|
|
43
|
+
panel="#0d0b08",
|
|
44
|
+
dark=True,
|
|
45
|
+
variables={
|
|
46
|
+
"block-cursor-background": "#d4af37",
|
|
47
|
+
"block-cursor-foreground": "#000000",
|
|
48
|
+
"block-cursor-text-style": "bold",
|
|
49
|
+
"block-cursor-blurred-background": "#d4af37 45%",
|
|
50
|
+
"block-cursor-blurred-foreground": "#000000",
|
|
51
|
+
"block-hover-background": "#d4af37 12%",
|
|
52
|
+
"scrollbar": "#d4af37 55%",
|
|
53
|
+
"scrollbar-hover": "#e8c55a",
|
|
54
|
+
"scrollbar-active": "#ffd84d",
|
|
55
|
+
"scrollbar-background": "#0d0b08",
|
|
56
|
+
"scrollbar-background-hover": "#0d0b08",
|
|
57
|
+
"scrollbar-background-active": "#0d0b08",
|
|
58
|
+
"scrollbar-corner-color": "#0d0b08",
|
|
59
|
+
"footer-key-foreground": "#d4af37",
|
|
60
|
+
"footer-foreground": "#7a7a7a",
|
|
61
|
+
"footer-background": "#000000",
|
|
62
|
+
"footer-description-foreground": "#9a9080",
|
|
63
|
+
"input-cursor-background": "#d4af37",
|
|
64
|
+
"input-cursor-foreground": "#000000",
|
|
65
|
+
"input-selection-background": "#d4af37 35%",
|
|
66
|
+
"screen-selection-background": "#d4af37 35%",
|
|
67
|
+
"border": "#d4af37",
|
|
68
|
+
"border-blurred": "#3a3020",
|
|
69
|
+
"link-color": "#ffd84d",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BitCraftApp(App):
|
|
75
|
+
"""Root Textual application using modes for the main investigation screens.
|
|
76
|
+
|
|
77
|
+
Flow: splash -> boot -> dashboard. Modes: dashboard, threats, graph.
|
|
78
|
+
Detail screens push on top of the active mode.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
CSS_PATH = "theme.tcss"
|
|
82
|
+
TITLE = "BitCraft"
|
|
83
|
+
MODES = {
|
|
84
|
+
"dashboard": DashboardScreen,
|
|
85
|
+
"graph": GraphExplorerScreen,
|
|
86
|
+
"threats": ThreatDetectionScreen,
|
|
87
|
+
"wallets": WalletsScreen,
|
|
88
|
+
}
|
|
89
|
+
DEFAULT_MODE = "dashboard"
|
|
90
|
+
BINDINGS = [
|
|
91
|
+
Binding("q", "quit", "Quit", priority=True),
|
|
92
|
+
Binding("d", "show_dashboard", "Dashboard", priority=True),
|
|
93
|
+
Binding("g", "show_graph", "Graph", priority=True),
|
|
94
|
+
Binding("t", "show_threats", "Threats", priority=True),
|
|
95
|
+
Binding("w", "show_wallets", "Wallets", priority=True),
|
|
96
|
+
Binding("escape", "go_home", "Home", priority=True),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def __init__(self, store: Optional[Store] = None) -> None:
|
|
100
|
+
super().__init__()
|
|
101
|
+
if store is None:
|
|
102
|
+
store = Store(
|
|
103
|
+
provider=DemoProvider(simulate_latency=False),
|
|
104
|
+
is_demo=True,
|
|
105
|
+
boot_log=["demo default"],
|
|
106
|
+
fast_boot=True,
|
|
107
|
+
)
|
|
108
|
+
self.store = store
|
|
109
|
+
self.register_theme(BITCRAFT_THEME)
|
|
110
|
+
self.theme = "bitcraft"
|
|
111
|
+
|
|
112
|
+
def clear_selection(self) -> None:
|
|
113
|
+
"""Ignore the startup window where the mode stack is still empty.
|
|
114
|
+
|
|
115
|
+
Textual mounts the DEFAULT_MODE screen before registering its stack,
|
|
116
|
+
and with eager tasks the dashboard's filter Inputs run their selection
|
|
117
|
+
watcher (which calls this) inside that window. Textual only catches
|
|
118
|
+
NoScreen here, so a real `app.run()` crashed with ScreenStackError.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
super().clear_selection()
|
|
122
|
+
except ScreenStackError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def on_mount(self) -> None:
|
|
126
|
+
"""Open on the brand splash above the default dashboard mode."""
|
|
127
|
+
self.push_screen(SplashScreen())
|
|
128
|
+
|
|
129
|
+
def _pop_overlays(self) -> None:
|
|
130
|
+
"""Pop pushed overlays until a mode screen is on top."""
|
|
131
|
+
mode_types = (DashboardScreen, GraphExplorerScreen, ThreatDetectionScreen, WalletsScreen)
|
|
132
|
+
while len(self.screen_stack) > 1 and not isinstance(self.screen, mode_types):
|
|
133
|
+
self.pop_screen()
|
|
134
|
+
|
|
135
|
+
def action_show_dashboard(self) -> None:
|
|
136
|
+
"""Switch to dashboard mode (ignored on splash)."""
|
|
137
|
+
if isinstance(self.screen, (SplashScreen, BootScreen)):
|
|
138
|
+
return
|
|
139
|
+
self.switch_mode("dashboard")
|
|
140
|
+
self._pop_overlays()
|
|
141
|
+
|
|
142
|
+
def action_show_graph(self) -> None:
|
|
143
|
+
"""Switch to graph mode."""
|
|
144
|
+
if isinstance(self.screen, (SplashScreen, BootScreen)):
|
|
145
|
+
return
|
|
146
|
+
self.switch_mode("graph")
|
|
147
|
+
self._pop_overlays()
|
|
148
|
+
|
|
149
|
+
def action_show_threats(self) -> None:
|
|
150
|
+
"""Switch to threats mode."""
|
|
151
|
+
if isinstance(self.screen, (SplashScreen, BootScreen)):
|
|
152
|
+
return
|
|
153
|
+
self.switch_mode("threats")
|
|
154
|
+
self._pop_overlays()
|
|
155
|
+
|
|
156
|
+
def action_show_wallets(self) -> None:
|
|
157
|
+
"""Switch to wallets mode."""
|
|
158
|
+
if isinstance(self.screen, (SplashScreen, BootScreen)):
|
|
159
|
+
return
|
|
160
|
+
self.switch_mode("wallets")
|
|
161
|
+
self._pop_overlays()
|
|
162
|
+
|
|
163
|
+
def action_go_home(self) -> None:
|
|
164
|
+
"""Return to the ASCII-logo splash above the current mode."""
|
|
165
|
+
if isinstance(self.screen, SplashScreen):
|
|
166
|
+
return
|
|
167
|
+
if isinstance(self.screen, BootScreen):
|
|
168
|
+
return
|
|
169
|
+
while len(self.screen_stack) > 1:
|
|
170
|
+
self.pop_screen()
|
|
171
|
+
self.push_screen(SplashScreen())
|
|
172
|
+
|
|
173
|
+
def enter_from_splash(self) -> None:
|
|
174
|
+
"""Dismiss splash and open the boot loader."""
|
|
175
|
+
if isinstance(self.screen, SplashScreen):
|
|
176
|
+
self.pop_screen()
|
|
177
|
+
self.push_screen(BootScreen())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
181
|
+
"""CLI flags for data source selection."""
|
|
182
|
+
parser = argparse.ArgumentParser(description="BitCraft terminal interface")
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--demo",
|
|
185
|
+
action="store_true",
|
|
186
|
+
help="Force the deterministic demo provider",
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--api",
|
|
190
|
+
action="store_true",
|
|
191
|
+
help="Force the FastAPI provider",
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"--api-url",
|
|
195
|
+
default=os.environ.get("BITCRAFT_API_URL", DEFAULT_BASE_URL),
|
|
196
|
+
help=f"Backend base URL (default {DEFAULT_BASE_URL})",
|
|
197
|
+
)
|
|
198
|
+
return parser
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: Optional[list[str]] = None) -> None:
|
|
202
|
+
"""Run the BitCraft terminal interface."""
|
|
203
|
+
args = build_arg_parser().parse_args(argv)
|
|
204
|
+
source = resolve_source(cli_demo=args.demo, cli_api=args.api)
|
|
205
|
+
choice = create_provider(source=source, api_url=args.api_url)
|
|
206
|
+
store = Store(
|
|
207
|
+
provider=choice.provider,
|
|
208
|
+
boot_log=list(choice.boot_log),
|
|
209
|
+
is_demo=(choice.provider.source_label == "DEMO DATA"),
|
|
210
|
+
fast_boot=False,
|
|
211
|
+
)
|
|
212
|
+
app = BitCraftApp(store=store)
|
|
213
|
+
app.run()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
bitcraft/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The `bitcraft` command.
|
|
2
|
+
|
|
3
|
+
bitcraft open BitCraft in a new, large window
|
|
4
|
+
bitcraft run [options] same, with options
|
|
5
|
+
bitcraft demo new window with built-in demo data
|
|
6
|
+
bitcraft here [options] run inside the current terminal
|
|
7
|
+
bitcraft status check the API and the last pipeline run
|
|
8
|
+
bitcraft version print the version
|
|
9
|
+
|
|
10
|
+
Options for run / here:
|
|
11
|
+
--demo | --api force a data source (default: auto)
|
|
12
|
+
--api-url URL backend to use (default http://localhost:8000)
|
|
13
|
+
--size COLSxROWS window size for run / demo (default 190x52)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from bitcraft import __version__
|
|
23
|
+
from bitcraft.api_client import DEFAULT_BASE_URL
|
|
24
|
+
from bitcraft.launcher import DEFAULT_SIZE, Options, api_healthy, open_window, run_session
|
|
25
|
+
|
|
26
|
+
COMMANDS = ("run", "demo", "here", "status", "version")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _size(text: str) -> tuple[int, int]:
|
|
30
|
+
try:
|
|
31
|
+
cols, rows = (int(v) for v in text.lower().split("x"))
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise argparse.ArgumentTypeError("size must look like 190x52") from exc
|
|
34
|
+
if cols < 100 or rows < 30:
|
|
35
|
+
raise argparse.ArgumentTypeError("BitCraft needs at least 100x30")
|
|
36
|
+
return cols, rows
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _source_args(p: argparse.ArgumentParser) -> None:
|
|
40
|
+
group = p.add_mutually_exclusive_group()
|
|
41
|
+
group.add_argument("--demo", action="store_true", help="built-in demo data, no API")
|
|
42
|
+
group.add_argument("--api", action="store_true", help="require the API (no demo fallback)")
|
|
43
|
+
p.add_argument(
|
|
44
|
+
"--api-url",
|
|
45
|
+
default=os.environ.get("BITCRAFT_API_URL", DEFAULT_BASE_URL),
|
|
46
|
+
help=f"backend URL (default {DEFAULT_BASE_URL})",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
51
|
+
parser = argparse.ArgumentParser(
|
|
52
|
+
prog="bitcraft",
|
|
53
|
+
description="BitCraft: Bitcoin transaction intelligence in your terminal.",
|
|
54
|
+
epilog="Run `bitcraft` with no arguments to open it in a new window.",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument("-V", "--version", action="version", version=f"bitcraft {__version__}")
|
|
57
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
58
|
+
|
|
59
|
+
run = sub.add_parser("run", help="open in a new, large terminal window (default)")
|
|
60
|
+
_source_args(run)
|
|
61
|
+
run.add_argument("--size", type=_size, default=DEFAULT_SIZE, help="window size, e.g. 190x52")
|
|
62
|
+
|
|
63
|
+
demo = sub.add_parser("demo", help="open in a new window with demo data")
|
|
64
|
+
demo.add_argument("--size", type=_size, default=DEFAULT_SIZE, help="window size, e.g. 190x52")
|
|
65
|
+
|
|
66
|
+
here = sub.add_parser("here", help="run inside the current terminal")
|
|
67
|
+
_source_args(here)
|
|
68
|
+
|
|
69
|
+
status = sub.add_parser("status", help="check the API and the last pipeline run")
|
|
70
|
+
status.add_argument("--api-url", default=os.environ.get("BITCRAFT_API_URL", DEFAULT_BASE_URL))
|
|
71
|
+
|
|
72
|
+
sub.add_parser("version", help="print the version")
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _options(args: argparse.Namespace) -> Options:
|
|
77
|
+
source = "demo" if getattr(args, "demo", False) else ("api" if getattr(args, "api", False) else "auto")
|
|
78
|
+
return Options(
|
|
79
|
+
source=source,
|
|
80
|
+
api_url=getattr(args, "api_url", DEFAULT_BASE_URL),
|
|
81
|
+
size=getattr(args, "size", DEFAULT_SIZE),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _launch(opts: Options) -> int:
|
|
86
|
+
try:
|
|
87
|
+
where = open_window(opts)
|
|
88
|
+
except Exception as exc: # noqa: BLE001 - no GUI terminal: run right here
|
|
89
|
+
print(f"Could not open a new window ({exc}); starting here.")
|
|
90
|
+
return run_session(opts)
|
|
91
|
+
cols, rows = opts.size
|
|
92
|
+
print(f"BitCraft is opening in a new {where} window ({cols}x{rows}).")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _status(api_url: str) -> int:
|
|
97
|
+
print(f"bitcraft {__version__}")
|
|
98
|
+
if not api_healthy(api_url, timeout_s=1.5):
|
|
99
|
+
print(f"API {api_url} not reachable (BitCraft will use demo data)")
|
|
100
|
+
return 1
|
|
101
|
+
import httpx
|
|
102
|
+
|
|
103
|
+
stats = httpx.get(f"{api_url}/stats/summary", timeout=3).json()
|
|
104
|
+
pipeline = httpx.get(f"{api_url}/pipeline/status", timeout=3).json()
|
|
105
|
+
print(f"API {api_url} ok")
|
|
106
|
+
print(f"data {stats['total_transactions']:,} transactions, {stats['total_alerts']:,} alerts")
|
|
107
|
+
finished = pipeline.get("finished_at") or "-"
|
|
108
|
+
print(f"pipeline {pipeline.get('status', 'unknown')} (finished {finished})")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(argv: list[str] | None = None) -> int:
|
|
113
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
114
|
+
# Bare `bitcraft` (or `bitcraft --demo ...`) means `bitcraft run ...`.
|
|
115
|
+
if not argv or (argv[0] not in COMMANDS and argv[0] not in ("-h", "--help", "-V", "--version")):
|
|
116
|
+
argv.insert(0, "run")
|
|
117
|
+
args = build_parser().parse_args(argv)
|
|
118
|
+
|
|
119
|
+
if args.command == "version":
|
|
120
|
+
print(f"bitcraft {__version__}")
|
|
121
|
+
return 0
|
|
122
|
+
if args.command == "status":
|
|
123
|
+
return _status(args.api_url)
|
|
124
|
+
if args.command == "here":
|
|
125
|
+
return run_session(_options(args))
|
|
126
|
+
if args.command == "demo":
|
|
127
|
+
return _launch(Options(source="demo", size=args.size))
|
|
128
|
+
return _launch(_options(args))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure, unit-testable TUI helpers."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Primary driver tags from weighted score contributions.
|
|
2
|
+
|
|
3
|
+
Weights mirrored from ml/config.yaml (plans/plan.md section 7).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from bitcraft.providers.demo_provider import (
|
|
11
|
+
ANOMALY_WEIGHT,
|
|
12
|
+
COMMUNITY_WEIGHT,
|
|
13
|
+
MODEL_WEIGHT,
|
|
14
|
+
NETWORK_WEIGHT,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DriverTag = Literal["MODEL", "ANOMALY", "COMMUNITY", "NETWORK"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def weighted_parts(
|
|
21
|
+
anomaly: float, community: float, network: float, model: float | None = 0.0
|
|
22
|
+
) -> dict[DriverTag, float]:
|
|
23
|
+
"""Return weighted contribution of each composite component."""
|
|
24
|
+
return {
|
|
25
|
+
"MODEL": MODEL_WEIGHT * (model or 0.0),
|
|
26
|
+
"ANOMALY": ANOMALY_WEIGHT * anomaly,
|
|
27
|
+
"COMMUNITY": COMMUNITY_WEIGHT * community,
|
|
28
|
+
"NETWORK": NETWORK_WEIGHT * network,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def primary_driver(
|
|
33
|
+
anomaly: float, community: float, network: float, model: float | None = 0.0
|
|
34
|
+
) -> DriverTag:
|
|
35
|
+
"""Component with the largest weighted contribution."""
|
|
36
|
+
parts = weighted_parts(anomaly, community, network, model)
|
|
37
|
+
return max(parts, key=parts.get) # type: ignore[arg-type]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Number, score bar, and glyph formatting helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ascii_mode() -> bool:
|
|
9
|
+
"""True when BITCRAFT_ASCII=1 forces plain ASCII glyphs."""
|
|
10
|
+
return os.environ.get("BITCRAFT_ASCII", "").strip() == "1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_int(n: int) -> str:
|
|
14
|
+
"""Format an integer with thousands separators."""
|
|
15
|
+
return f"{n:,}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_pct(value: float, digits: int = 1) -> str:
|
|
19
|
+
"""Format a percentage value."""
|
|
20
|
+
return f"{value:.{digits}f}%"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def score_bar(score: float, width: int = 10) -> str:
|
|
24
|
+
"""Render a composite score as a filled bar like #######---."""
|
|
25
|
+
score = max(0.0, min(1.0, score))
|
|
26
|
+
filled = int(round(score * width))
|
|
27
|
+
fill = "#" if ascii_mode() else "#"
|
|
28
|
+
empty = "-"
|
|
29
|
+
return fill * filled + empty * (width - filled)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def network_cell(has_network: bool, signal: float) -> str:
|
|
33
|
+
"""Render network signal: n/a when missing, else ~0.42 modeled."""
|
|
34
|
+
if not has_network:
|
|
35
|
+
return "n/a"
|
|
36
|
+
return f"~{signal:.2f}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def modeled_mark(text: str) -> str:
|
|
40
|
+
"""Prefix modeled values with the ~ glyph."""
|
|
41
|
+
if text.startswith("~"):
|
|
42
|
+
return text
|
|
43
|
+
return f"~{text}"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Display-only severity tiers derived from composite_score.
|
|
2
|
+
|
|
3
|
+
These thresholds are not model outputs. They should eventually be tuned
|
|
4
|
+
against precision@k from ml/config.yaml (see plans/future.md).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from bitcraft.providers.models import SeverityTier
|
|
10
|
+
|
|
11
|
+
# Display thresholds only. Not used by the ML pipeline.
|
|
12
|
+
SEVERITY_THRESHOLDS: tuple[tuple[SeverityTier, float], ...] = (
|
|
13
|
+
("critical", 0.80),
|
|
14
|
+
("high", 0.60),
|
|
15
|
+
("medium", 0.40),
|
|
16
|
+
("low", 0.0),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
SEVERITY_STYLE: dict[SeverityTier, str] = {
|
|
20
|
+
"critical": "alert-high",
|
|
21
|
+
"high": "alert-high",
|
|
22
|
+
"medium": "alert-medium",
|
|
23
|
+
"low": "alert-low",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def severity_for_score(composite_score: float) -> SeverityTier:
|
|
28
|
+
"""Map a composite score to a display severity tier."""
|
|
29
|
+
for tier, floor in SEVERITY_THRESHOLDS:
|
|
30
|
+
if composite_score >= floor:
|
|
31
|
+
return tier
|
|
32
|
+
return "low"
|