chatsbom 0.2.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.
chatsbom/__init__.py ADDED
File without changes
chatsbom/__main__.py ADDED
@@ -0,0 +1,27 @@
1
+ import typer
2
+
3
+ from chatsbom.commands import chat
4
+ from chatsbom.commands import collect
5
+ from chatsbom.commands import convert
6
+ from chatsbom.commands import download
7
+ from chatsbom.commands import index
8
+ from chatsbom.commands import query
9
+ from chatsbom.commands import status
10
+
11
+ app = typer.Typer(
12
+ help='ChatSBOM - Talk to your Supply Chain. Chat with SBOMs.',
13
+ no_args_is_help=True,
14
+ add_completion=False,
15
+ )
16
+
17
+ # Commands ordered by typical workflow
18
+ app.command(name='collect')(collect.main)
19
+ app.command(name='download')(download.main)
20
+ app.command(name='convert')(convert.main)
21
+ app.command(name='index')(index.main)
22
+ app.command(name='status')(status.main)
23
+ app.command(name='chat')(chat.main)
24
+ app.command(name='query')(query.main)
25
+
26
+ if __name__ == '__main__':
27
+ app()
@@ -0,0 +1 @@
1
+ # CLI Commands
@@ -0,0 +1,297 @@
1
+ """ChatSBOM Agent - TUI for querying SBOM database via Claude."""
2
+ import asyncio
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from dataclasses import field
7
+ from datetime import datetime
8
+
9
+ import dotenv
10
+ import typer
11
+ from claude_agent_sdk import ClaudeAgentOptions
12
+ from claude_agent_sdk.client import ClaudeSDKClient
13
+ from claude_agent_sdk.types import AssistantMessage
14
+ from claude_agent_sdk.types import McpStdioServerConfig
15
+ from claude_agent_sdk.types import ResultMessage
16
+ from claude_agent_sdk.types import TextBlock
17
+ from claude_agent_sdk.types import ThinkingBlock
18
+ from claude_agent_sdk.types import ToolResultBlock
19
+ from claude_agent_sdk.types import ToolUseBlock
20
+ from claude_agent_sdk.types import UserMessage
21
+ from rich.markdown import Markdown
22
+ from rich.table import Table
23
+ from textual import work
24
+ from textual.app import App
25
+ from textual.app import ComposeResult
26
+ from textual.binding import Binding
27
+ from textual.reactive import reactive
28
+ from textual.widgets import Footer
29
+ from textual.widgets import Header
30
+ from textual.widgets import Input
31
+ from textual.widgets import LoadingIndicator
32
+ from textual.widgets import RichLog
33
+ from textual.widgets import Static
34
+
35
+ dotenv.load_dotenv()
36
+
37
+ SYSTEM_PROMPT = (
38
+ 'You are an expert for querying the SBOM database. '
39
+ 'You can ONLY use the mcp-clickhouse tool to query the database. '
40
+ 'Do NOT attempt to read files, write files, or execute bash commands. '
41
+ 'Always use the mcp-clickhouse tool to query data. '
42
+ 'For large exports, format your answer and tell the user how many results there are.'
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class ClickHouseConfig:
48
+ """ClickHouse connection configuration."""
49
+ host: str = field(
50
+ default_factory=lambda: os.getenv(
51
+ 'CLICKHOUSE_HOST', 'localhost',
52
+ ),
53
+ )
54
+ port: str = field(
55
+ default_factory=lambda: os.getenv(
56
+ 'CLICKHOUSE_PORT', '8123',
57
+ ),
58
+ )
59
+ user: str = field(
60
+ default_factory=lambda: os.getenv(
61
+ 'CLICKHOUSE_USER', 'guest',
62
+ ),
63
+ )
64
+ password: str = field(
65
+ default_factory=lambda: os.getenv(
66
+ 'CLICKHOUSE_PASSWORD', 'guest',
67
+ ),
68
+ )
69
+
70
+ def to_env(self) -> dict[str, str]:
71
+ return {
72
+ 'CLICKHOUSE_HOST': self.host,
73
+ 'CLICKHOUSE_PORT': self.port,
74
+ 'CLICKHOUSE_USER': self.user,
75
+ 'CLICKHOUSE_PASSWORD': self.password,
76
+ 'CLICKHOUSE_ROLE': '',
77
+ 'CLICKHOUSE_SECURE': 'false',
78
+ 'CLICKHOUSE_VERIFY': 'false',
79
+ 'CLICKHOUSE_CONNECT_TIMEOUT': '16',
80
+ 'CLICKHOUSE_SEND_RECEIVE_TIMEOUT': '60',
81
+ }
82
+
83
+
84
+ class SBOMInsightApp(App):
85
+ """ChatSBOM Agent TUI."""
86
+
87
+ CSS = """
88
+ Screen { layout: grid; grid-size: 1; grid-rows: 1fr auto auto auto auto; }
89
+ RichLog { border: solid green; }
90
+ #status { height: 1; background: $primary-background; padding: 0 1; }
91
+ #loading { height: 1; }
92
+ .hidden { display: none; }
93
+ """
94
+
95
+ BINDINGS = [
96
+ Binding('ctrl+c', 'quit', 'Quit'),
97
+ Binding('ctrl+l', 'clear', 'Clear'),
98
+ ]
99
+ is_loading = reactive(False)
100
+
101
+ def __init__(self, db_config: ClickHouseConfig):
102
+ super().__init__()
103
+ self.db_config = db_config
104
+ self.client: ClaudeSDKClient | None = None
105
+ self.stats = {'cost': 0.0, 'turns': 0, 'in': 0, 'out': 0, 'ms': 0}
106
+
107
+ def compose(self) -> ComposeResult:
108
+ yield Header(show_clock=True)
109
+ yield RichLog(id='log', highlight=True, markup=True)
110
+ yield LoadingIndicator(id='loading')
111
+ yield Static(id='status')
112
+ yield Input(placeholder="Enter query ('exit' to quit)...", id='input')
113
+ yield Footer()
114
+
115
+ def watch_is_loading(self, loading: bool) -> None:
116
+ self.query_one('#loading').set_class(not loading, 'hidden')
117
+ inp = self.query_one('#input', Input)
118
+ inp.disabled = loading
119
+ if not loading:
120
+ inp.focus()
121
+ self._update_status()
122
+
123
+ async def on_mount(self) -> None:
124
+ opts = ClaudeAgentOptions(
125
+ disallowed_tools=[
126
+ 'Read', 'Write', 'Edit',
127
+ 'MultiEdit', 'Bash', 'Glob', 'Grep', 'LS',
128
+ ],
129
+ permission_mode='bypassPermissions',
130
+ mcp_servers={
131
+ 'mcp-clickhouse': McpStdioServerConfig(
132
+ command='uvx', args=['mcp-clickhouse'], env=self.db_config.to_env(),
133
+ ),
134
+ },
135
+ system_prompt=SYSTEM_PROMPT,
136
+ )
137
+ self.client = ClaudeSDKClient(options=opts)
138
+ await self.client.__aenter__()
139
+
140
+ log = self.query_one('#log', RichLog)
141
+ log.write('[bold green]ChatSBOM Agent[/] - Query examples:')
142
+ log.write(' • Top 10 projects using gin framework')
143
+ log.write(' • Top 5 Python libraries')
144
+ self.query_one('#loading').add_class('hidden')
145
+ self._update_status()
146
+
147
+ async def on_unmount(self) -> None:
148
+ if self.client:
149
+ try:
150
+ await self.client.__aexit__(None, None, None)
151
+ except (RuntimeError, asyncio.CancelledError):
152
+ pass
153
+
154
+ def _update_status(self) -> None:
155
+ s = self.stats
156
+ if s['turns']:
157
+ cny = s['cost'] * 7.2
158
+ text = (
159
+ f"🔄 {s['turns']} turns | "
160
+ f"📊 {s['in']:,} in / {s['out']:,} out | "
161
+ f"⏱ {s['ms']:,}ms | "
162
+ f"💰 ${s['cost']:.4f} / ¥{cny:.4f}"
163
+ )
164
+ else:
165
+ text = '✨ Ready'
166
+ self.query_one('#status', Static).update(text)
167
+
168
+ def action_clear(self) -> None:
169
+ self.query_one('#log', RichLog).clear()
170
+
171
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
172
+ query = event.value.strip()
173
+ self.query_one('#input', Input).value = ''
174
+ if not query:
175
+ return
176
+ if query.lower() in ('exit', 'quit'):
177
+ self.exit()
178
+ return
179
+ self.query_one('#log', RichLog).write(f'[bold blue]>>> {query}[/]')
180
+ self.process_query(query)
181
+
182
+ @work(exclusive=True)
183
+ async def process_query(self, query: str) -> None:
184
+ if not self.client:
185
+ return
186
+ log = self.query_one('#log', RichLog)
187
+ self.is_loading = True
188
+ try:
189
+ await self.client.query(query)
190
+ async for msg in self.client.receive_response():
191
+ self._render(msg, log)
192
+ except Exception as e:
193
+ log.write(f'[red]Error: {e}[/]')
194
+ finally:
195
+ self.is_loading = False
196
+
197
+ def _render(self, msg, log: RichLog) -> None:
198
+ """Render a message to the log."""
199
+ if isinstance(msg, AssistantMessage):
200
+ for b in msg.content:
201
+ self._render_block(b, log)
202
+ elif isinstance(msg, UserMessage) and isinstance(msg.content, list):
203
+ for b in msg.content:
204
+ self._render_block(b, log)
205
+ elif isinstance(msg, ResultMessage):
206
+ self.stats.update({
207
+ 'cost': msg.total_cost_usd or 0,
208
+ 'turns': msg.num_turns,
209
+ 'in': (msg.usage or {}).get('input_tokens', 0),
210
+ 'out': (msg.usage or {}).get('output_tokens', 0),
211
+ 'ms': msg.duration_ms,
212
+ })
213
+ s = self.stats
214
+ cny = s['cost'] * 7.2
215
+ log.write(
216
+ f"[dim]{datetime.now():%H:%M:%S} | "
217
+ f"{s['ms']:,}ms | "
218
+ f"{s['in']:,} in / {s['out']:,} out | "
219
+ f"${s['cost']:.4f} / ¥{cny:.4f}[/]",
220
+ )
221
+ self._update_status()
222
+
223
+ def _render_block(self, block, log: RichLog) -> None:
224
+ """Render a content block to the log."""
225
+ if isinstance(block, TextBlock):
226
+ log.write(Markdown(block.text))
227
+ elif isinstance(block, ThinkingBlock):
228
+ log.write(f"[dim]💭 {block.thinking[:80]}...[/]")
229
+ elif isinstance(block, ToolUseBlock):
230
+ log.write(f'[cyan]⚙ {block.name}[/] [dim]{block.input}[/]')
231
+ elif isinstance(block, ToolResultBlock):
232
+ self._render_tool_result(block, log)
233
+
234
+ def _render_tool_result(self, block: ToolResultBlock, log: RichLog) -> None:
235
+ """Render tool result, converting JSON tables to rich tables."""
236
+ if block.is_error:
237
+ log.write(f'[red]✗ {block.content}[/]')
238
+ return
239
+ if not isinstance(block.content, str):
240
+ log.write('[green]✓[/]')
241
+ return
242
+ try:
243
+ data = json.loads(block.content)
244
+ if 'columns' in data and 'rows' in data:
245
+ t = Table(header_style='bold cyan')
246
+ for c in data['columns']:
247
+ if c.lower() == 'description':
248
+ t.add_column(
249
+ c, no_wrap=True,
250
+ overflow='ellipsis', max_width=50,
251
+ )
252
+ else:
253
+ t.add_column(c, no_wrap=True, overflow='ellipsis')
254
+ for r in data['rows']:
255
+ t.add_row(*[str(x) for x in r])
256
+ log.write(t)
257
+ else:
258
+ log.write(f'[green]✓[/] {block.content[:100]}')
259
+ except (json.JSONDecodeError, TypeError):
260
+ log.write('[green]✓[/]')
261
+
262
+
263
+ def main(
264
+ host: str = typer.Option('localhost', envvar='CLICKHOUSE_HOST'),
265
+ port: str = typer.Option('8123', envvar='CLICKHOUSE_PORT'),
266
+ user: str = typer.Option('guest', envvar='CLICKHOUSE_USER'),
267
+ password: str = typer.Option('guest', envvar='CLICKHOUSE_PASSWORD'),
268
+ ):
269
+ """Start an AI conversation about your SBOM data."""
270
+ from rich.console import Console
271
+ console = Console()
272
+
273
+ if not os.getenv('ANTHROPIC_API_KEY') and not os.getenv('ANTHROPIC_AUTH_TOKEN'):
274
+ console.print(
275
+ '[bold red]Error:[/] ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN is not set.\n\n'
276
+ 'The Agent requires an Anthropic API key for Claude. '
277
+ 'Please set the ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable:\n\n'
278
+ ' [cyan]export ANTHROPIC_API_KEY="your_api_key"[/]\n\n'
279
+ ' [cyan]or[/]\n\n'
280
+ ' [cyan]export ANTHROPIC_AUTH_TOKEN="your_auth_token"[/]\n\n'
281
+ 'Or add it to your [cyan].env[/] file:\n\n'
282
+ ' [cyan]ANTHROPIC_API_KEY=your_api_key[/]\n\n'
283
+ ' [cyan]or[/]\n\n'
284
+ ' [cyan]ANTHROPIC_AUTH_TOKEN=your_auth_token[/]\n\n'
285
+ 'You can get an API key at: '
286
+ '[link=https://console.anthropic.com/]https://console.anthropic.com/[/link]',
287
+ )
288
+ raise typer.Exit(1)
289
+
290
+ # Check ClickHouse connection before starting TUI
291
+ from chatsbom.core.clickhouse import check_clickhouse_connection
292
+ check_clickhouse_connection(
293
+ host=host, port=int(port), user=user, password=password,
294
+ database='sbom', console=console, require_database=True,
295
+ )
296
+
297
+ SBOMInsightApp(ClickHouseConfig(host, port, user, password)).run()