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 +0 -0
- chatsbom/__main__.py +27 -0
- chatsbom/commands/__init__.py +1 -0
- chatsbom/commands/chat.py +297 -0
- chatsbom/commands/collect.py +453 -0
- chatsbom/commands/convert.py +263 -0
- chatsbom/commands/download.py +293 -0
- chatsbom/commands/index.py +327 -0
- chatsbom/commands/query.py +174 -0
- chatsbom/commands/status.py +223 -0
- chatsbom/core/__init__.py +1 -0
- chatsbom/core/clickhouse.py +98 -0
- chatsbom/core/client.py +54 -0
- chatsbom/core/config.py +145 -0
- chatsbom/core/repository.py +327 -0
- chatsbom/core/schema.py +31 -0
- chatsbom/core/validation.py +149 -0
- chatsbom/models/__init__.py +0 -0
- chatsbom/models/framework.py +129 -0
- chatsbom/models/language.py +167 -0
- chatsbom-0.2.1.dist-info/METADATA +125 -0
- chatsbom-0.2.1.dist-info/RECORD +24 -0
- chatsbom-0.2.1.dist-info/WHEEL +4 -0
- chatsbom-0.2.1.dist-info/entry_points.txt +2 -0
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()
|