seizu-cli 2.0.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.
- seizu_cli/__init__.py +0 -0
- seizu_cli/__main__.py +4 -0
- seizu_cli/auth.py +365 -0
- seizu_cli/client.py +62 -0
- seizu_cli/commands/__init__.py +0 -0
- seizu_cli/commands/auth.py +84 -0
- seizu_cli/commands/reports.py +223 -0
- seizu_cli/commands/scheduled_queries.py +157 -0
- seizu_cli/commands/seed.py +852 -0
- seizu_cli/commands/skillsets.py +371 -0
- seizu_cli/commands/toolsets.py +506 -0
- seizu_cli/config.py +38 -0
- seizu_cli/main.py +181 -0
- seizu_cli/schema.py +31 -0
- seizu_cli/state.py +29 -0
- seizu_cli-2.0.0.dist-info/METADATA +13 -0
- seizu_cli-2.0.0.dist-info/RECORD +22 -0
- seizu_cli-2.0.0.dist-info/WHEEL +5 -0
- seizu_cli-2.0.0.dist-info/entry_points.txt +2 -0
- seizu_cli-2.0.0.dist-info/top_level.txt +2 -0
- seizu_schema/__init__.py +1 -0
- seizu_schema/reporting_config.py +939 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
"""seed / export commands — bulk-load or dump YAML config via the Seizu API."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from seizu_cli import schema, state
|
|
10
|
+
from seizu_cli.client import APIError
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
err_console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
SEED_COMMENT = "Imported from YAML dashboard config"
|
|
16
|
+
SEED_UPDATE_COMMENT = "Updated from YAML dashboard config"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _slugify(name: str) -> str:
|
|
20
|
+
slug = name.lower()
|
|
21
|
+
slug = re.sub(r"[^a-z0-9]+", "_", slug)
|
|
22
|
+
return slug.strip("_") or "report"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _die(exc: Exception) -> None:
|
|
26
|
+
if isinstance(exc, APIError):
|
|
27
|
+
err_console.print(f"[red]Error {exc.status_code}[/red]: {exc}")
|
|
28
|
+
else:
|
|
29
|
+
err_console.print(f"[red]Error[/red]: {exc}")
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _list_reports() -> list[dict[str, Any]]:
|
|
34
|
+
return state.get_client().get("/api/v1/reports").get("reports", [])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _publish_report(report_id: str) -> None:
|
|
38
|
+
state.get_client().put(f"/api/v1/reports/{report_id}/visibility", json={"access": {"scope": "public"}})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _pin_report(report_id: str, pinned: bool) -> None:
|
|
42
|
+
state.get_client().put(f"/api/v1/reports/{report_id}/pin", json={"pinned": pinned})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_report(report_id: str) -> dict[str, Any] | None:
|
|
46
|
+
try:
|
|
47
|
+
return state.get_client().get(f"/api/v1/reports/{report_id}")
|
|
48
|
+
except APIError as exc:
|
|
49
|
+
if exc.status_code == 404:
|
|
50
|
+
return None
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _list_scheduled_queries() -> list[dict[str, Any]]:
|
|
55
|
+
return state.get_client().get("/api/v1/scheduled-queries").get("scheduled_queries", [])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _sq_content_changed(
|
|
59
|
+
existing: dict[str, Any],
|
|
60
|
+
resolved_cypher: str,
|
|
61
|
+
params: list[dict[str, Any]],
|
|
62
|
+
frequency: int | None,
|
|
63
|
+
watch_scans: list[dict[str, Any]],
|
|
64
|
+
enabled: bool,
|
|
65
|
+
actions: list[dict[str, Any]],
|
|
66
|
+
) -> bool:
|
|
67
|
+
return (
|
|
68
|
+
existing.get("cypher") != resolved_cypher
|
|
69
|
+
or existing.get("params") != params
|
|
70
|
+
or existing.get("frequency") != frequency
|
|
71
|
+
or existing.get("watch_scans") != watch_scans
|
|
72
|
+
or existing.get("enabled", True) != enabled
|
|
73
|
+
or existing.get("actions") != actions
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _list_toolsets() -> list[dict[str, Any]]:
|
|
78
|
+
return state.get_client().get("/api/v1/toolsets").get("toolsets", [])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _list_tools(toolset_id: str) -> list[dict[str, Any]]:
|
|
82
|
+
return state.get_client().get(f"/api/v1/toolsets/{toolset_id}/tools").get("tools", [])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _list_skillsets() -> list[dict[str, Any]]:
|
|
86
|
+
return state.get_client().get("/api/v1/skillsets").get("skillsets", [])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _list_skills(skillset_id: str) -> list[dict[str, Any]]:
|
|
90
|
+
return state.get_client().get(f"/api/v1/skillsets/{skillset_id}/skills").get("skills", [])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _toolset_content_changed(
|
|
94
|
+
existing: dict[str, Any],
|
|
95
|
+
name: str,
|
|
96
|
+
description: str,
|
|
97
|
+
enabled: bool,
|
|
98
|
+
) -> bool:
|
|
99
|
+
return (
|
|
100
|
+
existing.get("name") != name
|
|
101
|
+
or existing.get("description", "") != description
|
|
102
|
+
or existing.get("enabled", True) != enabled
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _tool_content_changed(
|
|
107
|
+
existing: dict[str, Any],
|
|
108
|
+
name: str,
|
|
109
|
+
description: str,
|
|
110
|
+
cypher: str,
|
|
111
|
+
parameters: list[dict[str, Any]],
|
|
112
|
+
enabled: bool,
|
|
113
|
+
) -> bool:
|
|
114
|
+
return (
|
|
115
|
+
existing.get("name") != name
|
|
116
|
+
or existing.get("description", "") != description
|
|
117
|
+
or existing.get("cypher") != cypher
|
|
118
|
+
or existing.get("parameters", []) != parameters
|
|
119
|
+
or existing.get("enabled", True) != enabled
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _skillset_content_changed(
|
|
124
|
+
existing: dict[str, Any],
|
|
125
|
+
name: str,
|
|
126
|
+
description: str,
|
|
127
|
+
enabled: bool,
|
|
128
|
+
) -> bool:
|
|
129
|
+
return (
|
|
130
|
+
existing.get("name") != name
|
|
131
|
+
or existing.get("description", "") != description
|
|
132
|
+
or existing.get("enabled", True) != enabled
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _skill_content_changed(
|
|
137
|
+
existing: dict[str, Any],
|
|
138
|
+
name: str,
|
|
139
|
+
description: str,
|
|
140
|
+
template: str,
|
|
141
|
+
parameters: list[dict[str, Any]],
|
|
142
|
+
triggers: list[str],
|
|
143
|
+
tools_required: list[str],
|
|
144
|
+
enabled: bool,
|
|
145
|
+
) -> bool:
|
|
146
|
+
return (
|
|
147
|
+
existing.get("name") != name
|
|
148
|
+
or existing.get("description", "") != description
|
|
149
|
+
or existing.get("template") != template
|
|
150
|
+
or existing.get("parameters", []) != parameters
|
|
151
|
+
or existing.get("triggers", []) != triggers
|
|
152
|
+
or existing.get("tools_required", []) != tools_required
|
|
153
|
+
or existing.get("enabled", True) != enabled
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def seed_cmd(config: str, force: bool, dry_run: bool) -> None:
|
|
158
|
+
"""Seed reports, scheduled queries, toolsets, and skillsets from YAML."""
|
|
159
|
+
loaded = schema.load_file(config)
|
|
160
|
+
|
|
161
|
+
if not loaded.reports and not loaded.scheduled_queries and not loaded.toolsets and not loaded.skillsets:
|
|
162
|
+
console.print("No reports, scheduled queries, toolsets, or skillsets found in config file. Nothing to do.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
existing_list = _list_reports()
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
_die(exc)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
existing_by_name: dict[str, dict[str, Any]] = {r["name"]: r for r in existing_list}
|
|
172
|
+
|
|
173
|
+
created = updated = skipped = 0
|
|
174
|
+
seeded_ids: dict[str, str] = {}
|
|
175
|
+
|
|
176
|
+
for report_key, report in loaded.reports.items():
|
|
177
|
+
report_config_dict = report.model_dump(exclude_none=True, exclude={"pinned"})
|
|
178
|
+
existing = existing_by_name.get(report.name)
|
|
179
|
+
|
|
180
|
+
if existing:
|
|
181
|
+
if not force:
|
|
182
|
+
latest = _get_report(existing["report_id"])
|
|
183
|
+
if latest and latest.get("config") == report_config_dict:
|
|
184
|
+
try:
|
|
185
|
+
if existing.get("access", {}).get("scope") != "public":
|
|
186
|
+
_publish_report(existing["report_id"])
|
|
187
|
+
if report.pinned is not None and existing.get("pinned") != report.pinned:
|
|
188
|
+
_pin_report(existing["report_id"], report.pinned)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
_die(exc)
|
|
191
|
+
return
|
|
192
|
+
console.print(f"[dim][skip][/dim] '{report.name}' (config unchanged)")
|
|
193
|
+
skipped += 1
|
|
194
|
+
seeded_ids[report_key] = existing["report_id"]
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
if dry_run:
|
|
198
|
+
console.print(f"[yellow][dry-run][/yellow] would update report '{report.name}' (key: {report_key})")
|
|
199
|
+
updated += 1
|
|
200
|
+
seeded_ids[report_key] = existing["report_id"]
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
state.get_client().post(
|
|
205
|
+
f"/api/v1/reports/{existing['report_id']}/versions",
|
|
206
|
+
json={"config": report_config_dict, "comment": SEED_UPDATE_COMMENT},
|
|
207
|
+
)
|
|
208
|
+
_publish_report(existing["report_id"])
|
|
209
|
+
if report.pinned is not None:
|
|
210
|
+
_pin_report(existing["report_id"], report.pinned)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
_die(exc)
|
|
213
|
+
return
|
|
214
|
+
seeded_ids[report_key] = existing["report_id"]
|
|
215
|
+
console.print(
|
|
216
|
+
f"[blue][updated][/blue] '{existing['report_id']}' name='{report.name}' yaml_key='{report_key}'"
|
|
217
|
+
)
|
|
218
|
+
updated += 1
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if dry_run:
|
|
222
|
+
console.print(f"[yellow][dry-run][/yellow] would create report '{report.name}' (key: {report_key})")
|
|
223
|
+
created += 1
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
new_report = state.get_client().post("/api/v1/reports", json={"name": report.name})
|
|
228
|
+
state.get_client().post(
|
|
229
|
+
f"/api/v1/reports/{new_report['report_id']}/versions",
|
|
230
|
+
json={"config": report_config_dict, "comment": SEED_COMMENT},
|
|
231
|
+
)
|
|
232
|
+
_publish_report(new_report["report_id"])
|
|
233
|
+
if report.pinned is not None:
|
|
234
|
+
_pin_report(new_report["report_id"], report.pinned)
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
_die(exc)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
seeded_ids[report_key] = new_report["report_id"]
|
|
240
|
+
console.print(
|
|
241
|
+
f"[green][created][/green] '{new_report['report_id']}' name='{report.name}' yaml_key='{report_key}'"
|
|
242
|
+
)
|
|
243
|
+
created += 1
|
|
244
|
+
|
|
245
|
+
if loaded.dashboard and not dry_run:
|
|
246
|
+
dashboard_id = seeded_ids.get(loaded.dashboard)
|
|
247
|
+
if dashboard_id:
|
|
248
|
+
try:
|
|
249
|
+
state.get_client().put(f"/api/v1/reports/{dashboard_id}/dashboard")
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
_die(exc)
|
|
252
|
+
return
|
|
253
|
+
console.print(f"[green][dashboard][/green] set to '{dashboard_id}' (key: {loaded.dashboard})")
|
|
254
|
+
else:
|
|
255
|
+
msg = (
|
|
256
|
+
f"[yellow][warn][/yellow] dashboard key '{loaded.dashboard}'"
|
|
257
|
+
" was not seeded, dashboard pointer not updated"
|
|
258
|
+
)
|
|
259
|
+
console.print(msg)
|
|
260
|
+
|
|
261
|
+
console.print(f"\nReports: created={created} updated={updated} skipped={skipped}")
|
|
262
|
+
console.print("\nSeeding scheduled queries...")
|
|
263
|
+
_seed_scheduled_queries(loaded, force=force, dry_run=dry_run)
|
|
264
|
+
|
|
265
|
+
console.print("\nSeeding toolsets...")
|
|
266
|
+
_seed_toolsets(loaded, force=force, dry_run=dry_run)
|
|
267
|
+
|
|
268
|
+
console.print("\nSeeding skillsets...")
|
|
269
|
+
_seed_skillsets(loaded, force=force, dry_run=dry_run)
|
|
270
|
+
|
|
271
|
+
if dry_run:
|
|
272
|
+
console.print("\n(dry-run, no writes performed)")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _seed_scheduled_queries(
|
|
276
|
+
config: Any,
|
|
277
|
+
force: bool,
|
|
278
|
+
dry_run: bool,
|
|
279
|
+
) -> None:
|
|
280
|
+
if not config.scheduled_queries:
|
|
281
|
+
console.print(" No scheduled queries in config, skipping.")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
existing_items: dict[str, dict[str, Any]] = {item["name"]: item for item in _list_scheduled_queries()}
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
_die(exc)
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
created = updated = skipped = 0
|
|
291
|
+
|
|
292
|
+
for sq in config.scheduled_queries:
|
|
293
|
+
resolved_cypher: str = config.queries.get(sq.cypher, sq.cypher)
|
|
294
|
+
params = [p.model_dump() for p in sq.params]
|
|
295
|
+
watch_scans = [ws.model_dump() for ws in sq.watch_scans]
|
|
296
|
+
actions = [a.model_dump() for a in sq.actions]
|
|
297
|
+
enabled = sq.enabled if sq.enabled is not None else True
|
|
298
|
+
frequency: int | None = sq.frequency
|
|
299
|
+
|
|
300
|
+
existing = existing_items.get(sq.name)
|
|
301
|
+
|
|
302
|
+
if existing:
|
|
303
|
+
changed = force or _sq_content_changed(
|
|
304
|
+
existing,
|
|
305
|
+
resolved_cypher,
|
|
306
|
+
params,
|
|
307
|
+
frequency,
|
|
308
|
+
watch_scans,
|
|
309
|
+
enabled,
|
|
310
|
+
actions,
|
|
311
|
+
)
|
|
312
|
+
if not changed:
|
|
313
|
+
console.print(f" [dim][skip][/dim] scheduled query '{sq.name}' (unchanged)")
|
|
314
|
+
skipped += 1
|
|
315
|
+
continue
|
|
316
|
+
if dry_run:
|
|
317
|
+
console.print(f" [yellow][dry-run][/yellow] would update scheduled query '{sq.name}'")
|
|
318
|
+
updated += 1
|
|
319
|
+
continue
|
|
320
|
+
try:
|
|
321
|
+
state.get_client().put(
|
|
322
|
+
f"/api/v1/scheduled-queries/{existing['scheduled_query_id']}",
|
|
323
|
+
json={
|
|
324
|
+
"name": sq.name,
|
|
325
|
+
"cypher": resolved_cypher,
|
|
326
|
+
"params": params,
|
|
327
|
+
"frequency": frequency,
|
|
328
|
+
"watch_scans": watch_scans,
|
|
329
|
+
"enabled": enabled,
|
|
330
|
+
"actions": actions,
|
|
331
|
+
"comment": SEED_UPDATE_COMMENT,
|
|
332
|
+
},
|
|
333
|
+
)
|
|
334
|
+
except Exception as exc:
|
|
335
|
+
_die(exc)
|
|
336
|
+
return
|
|
337
|
+
console.print(f" [blue][updated][/blue] '{existing['scheduled_query_id']}' name='{sq.name}'")
|
|
338
|
+
updated += 1
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
if dry_run:
|
|
342
|
+
console.print(f" [yellow][dry-run][/yellow] would create scheduled query '{sq.name}'")
|
|
343
|
+
created += 1
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
result = state.get_client().post(
|
|
348
|
+
"/api/v1/scheduled-queries",
|
|
349
|
+
json={
|
|
350
|
+
"name": sq.name,
|
|
351
|
+
"cypher": resolved_cypher,
|
|
352
|
+
"params": params,
|
|
353
|
+
"frequency": frequency,
|
|
354
|
+
"watch_scans": watch_scans,
|
|
355
|
+
"enabled": enabled,
|
|
356
|
+
"actions": actions,
|
|
357
|
+
},
|
|
358
|
+
)
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
_die(exc)
|
|
361
|
+
return
|
|
362
|
+
console.print(f" [green][created][/green] '{result['scheduled_query_id']}' name='{sq.name}'")
|
|
363
|
+
created += 1
|
|
364
|
+
|
|
365
|
+
console.print(f" Scheduled queries: created={created} updated={updated} skipped={skipped}")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _seed_toolsets(
|
|
369
|
+
config: Any,
|
|
370
|
+
force: bool,
|
|
371
|
+
dry_run: bool,
|
|
372
|
+
) -> None:
|
|
373
|
+
if not config.toolsets:
|
|
374
|
+
console.print(" No toolsets in config, skipping.")
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
existing_toolsets: dict[str, dict[str, Any]] = {item["toolset_id"]: item for item in _list_toolsets()}
|
|
379
|
+
except Exception as exc:
|
|
380
|
+
_die(exc)
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
ts_created = ts_updated = ts_skipped = 0
|
|
384
|
+
|
|
385
|
+
for ts_key, ts_def in config.toolsets.items():
|
|
386
|
+
existing_ts = existing_toolsets.get(ts_key)
|
|
387
|
+
description = ts_def.description or ""
|
|
388
|
+
enabled = ts_def.enabled if ts_def.enabled is not None else True
|
|
389
|
+
|
|
390
|
+
if existing_ts:
|
|
391
|
+
changed = force or _toolset_content_changed(existing_ts, ts_def.name, description, enabled)
|
|
392
|
+
if not changed:
|
|
393
|
+
console.print(f" [dim][skip][/dim] toolset '{ts_def.name}' (unchanged)")
|
|
394
|
+
ts_skipped += 1
|
|
395
|
+
toolset_id = existing_ts["toolset_id"]
|
|
396
|
+
elif dry_run:
|
|
397
|
+
console.print(f" [yellow][dry-run][/yellow] would update toolset '{ts_def.name}' (key: {ts_key})")
|
|
398
|
+
ts_updated += 1
|
|
399
|
+
toolset_id = existing_ts["toolset_id"]
|
|
400
|
+
else:
|
|
401
|
+
try:
|
|
402
|
+
state.get_client().put(
|
|
403
|
+
f"/api/v1/toolsets/{existing_ts['toolset_id']}",
|
|
404
|
+
json={
|
|
405
|
+
"name": ts_def.name,
|
|
406
|
+
"description": description,
|
|
407
|
+
"enabled": enabled,
|
|
408
|
+
"comment": SEED_UPDATE_COMMENT,
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
_die(exc)
|
|
413
|
+
return
|
|
414
|
+
console.print(
|
|
415
|
+
f" [blue][updated][/blue] '{existing_ts['toolset_id']}' name='{ts_def.name}' yaml_key='{ts_key}'"
|
|
416
|
+
)
|
|
417
|
+
ts_updated += 1
|
|
418
|
+
toolset_id = existing_ts["toolset_id"]
|
|
419
|
+
elif dry_run:
|
|
420
|
+
console.print(f" [yellow][dry-run][/yellow] would create toolset '{ts_def.name}' (key: {ts_key})")
|
|
421
|
+
ts_created += 1
|
|
422
|
+
toolset_id = None
|
|
423
|
+
else:
|
|
424
|
+
try:
|
|
425
|
+
result = state.get_client().post(
|
|
426
|
+
"/api/v1/toolsets",
|
|
427
|
+
json={
|
|
428
|
+
"toolset_id": ts_key,
|
|
429
|
+
"name": ts_def.name,
|
|
430
|
+
"description": description,
|
|
431
|
+
"enabled": enabled,
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
_die(exc)
|
|
436
|
+
return
|
|
437
|
+
console.print(
|
|
438
|
+
f" [green][created][/green] '{result['toolset_id']}' name='{ts_def.name}' yaml_key='{ts_key}'"
|
|
439
|
+
)
|
|
440
|
+
ts_created += 1
|
|
441
|
+
toolset_id = ts_key
|
|
442
|
+
|
|
443
|
+
if toolset_id is None or not ts_def.tools:
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
existing_tools: dict[str, dict[str, Any]] = {t["tool_id"]: t for t in _list_tools(toolset_id)}
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
_die(exc)
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
for tool_key, tool_def in ts_def.tools.items():
|
|
453
|
+
tool_description = tool_def.description or ""
|
|
454
|
+
tool_enabled = tool_def.enabled if tool_def.enabled is not None else True
|
|
455
|
+
tool_params = [p.model_dump() for p in tool_def.parameters]
|
|
456
|
+
existing_tool = existing_tools.get(tool_key)
|
|
457
|
+
|
|
458
|
+
if existing_tool:
|
|
459
|
+
tool_changed = force or _tool_content_changed(
|
|
460
|
+
existing_tool,
|
|
461
|
+
tool_def.name,
|
|
462
|
+
tool_description,
|
|
463
|
+
tool_def.cypher,
|
|
464
|
+
tool_params,
|
|
465
|
+
tool_enabled,
|
|
466
|
+
)
|
|
467
|
+
if not tool_changed:
|
|
468
|
+
console.print(f" [dim][skip][/dim] tool '{tool_def.name}' (unchanged)")
|
|
469
|
+
elif dry_run:
|
|
470
|
+
console.print(
|
|
471
|
+
f" [yellow][dry-run][/yellow] would update tool '{tool_def.name}' (key: {tool_key})"
|
|
472
|
+
)
|
|
473
|
+
else:
|
|
474
|
+
try:
|
|
475
|
+
state.get_client().put(
|
|
476
|
+
f"/api/v1/toolsets/{toolset_id}/tools/{existing_tool['tool_id']}",
|
|
477
|
+
json={
|
|
478
|
+
"name": tool_def.name,
|
|
479
|
+
"description": tool_description,
|
|
480
|
+
"cypher": tool_def.cypher,
|
|
481
|
+
"parameters": tool_params,
|
|
482
|
+
"enabled": tool_enabled,
|
|
483
|
+
"comment": SEED_UPDATE_COMMENT,
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
except Exception as exc:
|
|
487
|
+
_die(exc)
|
|
488
|
+
return
|
|
489
|
+
console.print(
|
|
490
|
+
f" [blue][updated][/blue] '{existing_tool['tool_id']}'"
|
|
491
|
+
f" name='{tool_def.name}' yaml_key='{tool_key}'"
|
|
492
|
+
)
|
|
493
|
+
elif dry_run:
|
|
494
|
+
console.print(f" [yellow][dry-run][/yellow] would create tool '{tool_def.name}' (key: {tool_key})")
|
|
495
|
+
else:
|
|
496
|
+
try:
|
|
497
|
+
result = state.get_client().post(
|
|
498
|
+
f"/api/v1/toolsets/{toolset_id}/tools",
|
|
499
|
+
json={
|
|
500
|
+
"tool_id": tool_key,
|
|
501
|
+
"name": tool_def.name,
|
|
502
|
+
"description": tool_description,
|
|
503
|
+
"cypher": tool_def.cypher,
|
|
504
|
+
"parameters": tool_params,
|
|
505
|
+
"enabled": tool_enabled,
|
|
506
|
+
},
|
|
507
|
+
)
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
_die(exc)
|
|
510
|
+
return
|
|
511
|
+
console.print(
|
|
512
|
+
f" [green][created][/green] '{result['tool_id']}' name='{tool_def.name}' yaml_key='{tool_key}'"
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
console.print(f" Toolsets: created={ts_created} updated={ts_updated} skipped={ts_skipped}")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _seed_skillsets(
|
|
519
|
+
config: Any,
|
|
520
|
+
force: bool,
|
|
521
|
+
dry_run: bool,
|
|
522
|
+
) -> None:
|
|
523
|
+
if not config.skillsets:
|
|
524
|
+
console.print(" No skillsets in config, skipping.")
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
existing_skillsets: dict[str, dict[str, Any]] = {item["skillset_id"]: item for item in _list_skillsets()}
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
_die(exc)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
ss_created = ss_updated = ss_skipped = 0
|
|
534
|
+
|
|
535
|
+
for ss_key, ss_def in config.skillsets.items():
|
|
536
|
+
existing_ss = existing_skillsets.get(ss_key)
|
|
537
|
+
description = ss_def.description or ""
|
|
538
|
+
enabled = ss_def.enabled if ss_def.enabled is not None else True
|
|
539
|
+
|
|
540
|
+
if existing_ss:
|
|
541
|
+
changed = force or _skillset_content_changed(existing_ss, ss_def.name, description, enabled)
|
|
542
|
+
if not changed:
|
|
543
|
+
console.print(f" [dim][skip][/dim] skillset '{ss_def.name}' (unchanged)")
|
|
544
|
+
ss_skipped += 1
|
|
545
|
+
skillset_id = existing_ss["skillset_id"]
|
|
546
|
+
elif dry_run:
|
|
547
|
+
console.print(f" [yellow][dry-run][/yellow] would update skillset '{ss_def.name}' (key: {ss_key})")
|
|
548
|
+
ss_updated += 1
|
|
549
|
+
skillset_id = existing_ss["skillset_id"]
|
|
550
|
+
else:
|
|
551
|
+
try:
|
|
552
|
+
state.get_client().put(
|
|
553
|
+
f"/api/v1/skillsets/{existing_ss['skillset_id']}",
|
|
554
|
+
json={
|
|
555
|
+
"name": ss_def.name,
|
|
556
|
+
"description": description,
|
|
557
|
+
"enabled": enabled,
|
|
558
|
+
"comment": SEED_UPDATE_COMMENT,
|
|
559
|
+
},
|
|
560
|
+
)
|
|
561
|
+
except Exception as exc:
|
|
562
|
+
_die(exc)
|
|
563
|
+
return
|
|
564
|
+
console.print(
|
|
565
|
+
f" [blue][updated][/blue] '{existing_ss['skillset_id']}'"
|
|
566
|
+
f" name='{ss_def.name}' yaml_key='{ss_key}'"
|
|
567
|
+
)
|
|
568
|
+
ss_updated += 1
|
|
569
|
+
skillset_id = existing_ss["skillset_id"]
|
|
570
|
+
elif dry_run:
|
|
571
|
+
console.print(f" [yellow][dry-run][/yellow] would create skillset '{ss_def.name}' (key: {ss_key})")
|
|
572
|
+
ss_created += 1
|
|
573
|
+
skillset_id = None
|
|
574
|
+
else:
|
|
575
|
+
try:
|
|
576
|
+
state.get_client().post(
|
|
577
|
+
"/api/v1/skillsets",
|
|
578
|
+
json={
|
|
579
|
+
"skillset_id": ss_key,
|
|
580
|
+
"name": ss_def.name,
|
|
581
|
+
"description": description,
|
|
582
|
+
"enabled": enabled,
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
except Exception as exc:
|
|
586
|
+
_die(exc)
|
|
587
|
+
return
|
|
588
|
+
console.print(f" [green][created][/green] '{ss_key}' name='{ss_def.name}' yaml_key='{ss_key}'")
|
|
589
|
+
ss_created += 1
|
|
590
|
+
skillset_id = ss_key
|
|
591
|
+
|
|
592
|
+
if skillset_id is None or not ss_def.skills:
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
existing_skills: dict[str, dict[str, Any]] = {s["skill_id"]: s for s in _list_skills(skillset_id)}
|
|
597
|
+
except Exception as exc:
|
|
598
|
+
_die(exc)
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
for skill_key, skill_def in ss_def.skills.items():
|
|
602
|
+
skill_description = skill_def.description or ""
|
|
603
|
+
skill_enabled = skill_def.enabled if skill_def.enabled is not None else True
|
|
604
|
+
skill_params = [p.model_dump() for p in skill_def.parameters]
|
|
605
|
+
skill_triggers = skill_def.triggers
|
|
606
|
+
skill_tools_required = skill_def.tools_required
|
|
607
|
+
existing_skill = existing_skills.get(skill_key)
|
|
608
|
+
|
|
609
|
+
if existing_skill:
|
|
610
|
+
skill_changed = force or _skill_content_changed(
|
|
611
|
+
existing_skill,
|
|
612
|
+
skill_def.name,
|
|
613
|
+
skill_description,
|
|
614
|
+
skill_def.template,
|
|
615
|
+
skill_params,
|
|
616
|
+
skill_triggers,
|
|
617
|
+
skill_tools_required,
|
|
618
|
+
skill_enabled,
|
|
619
|
+
)
|
|
620
|
+
if not skill_changed:
|
|
621
|
+
console.print(f" [dim][skip][/dim] skill '{skill_def.name}' (unchanged)")
|
|
622
|
+
elif dry_run:
|
|
623
|
+
console.print(
|
|
624
|
+
f" [yellow][dry-run][/yellow] would update skill '{skill_def.name}' (key: {skill_key})"
|
|
625
|
+
)
|
|
626
|
+
else:
|
|
627
|
+
try:
|
|
628
|
+
state.get_client().put(
|
|
629
|
+
f"/api/v1/skillsets/{skillset_id}/skills/{existing_skill['skill_id']}",
|
|
630
|
+
json={
|
|
631
|
+
"name": skill_def.name,
|
|
632
|
+
"description": skill_description,
|
|
633
|
+
"template": skill_def.template,
|
|
634
|
+
"parameters": skill_params,
|
|
635
|
+
"triggers": skill_triggers,
|
|
636
|
+
"tools_required": skill_tools_required,
|
|
637
|
+
"enabled": skill_enabled,
|
|
638
|
+
"comment": SEED_UPDATE_COMMENT,
|
|
639
|
+
},
|
|
640
|
+
)
|
|
641
|
+
except Exception as exc:
|
|
642
|
+
_die(exc)
|
|
643
|
+
return
|
|
644
|
+
console.print(
|
|
645
|
+
f" [blue][updated][/blue] '{existing_skill['skill_id']}'"
|
|
646
|
+
f" name='{skill_def.name}' yaml_key='{skill_key}'"
|
|
647
|
+
)
|
|
648
|
+
elif dry_run:
|
|
649
|
+
console.print(
|
|
650
|
+
f" [yellow][dry-run][/yellow] would create skill '{skill_def.name}' (key: {skill_key})"
|
|
651
|
+
)
|
|
652
|
+
else:
|
|
653
|
+
try:
|
|
654
|
+
result = state.get_client().post(
|
|
655
|
+
f"/api/v1/skillsets/{skillset_id}/skills",
|
|
656
|
+
json={
|
|
657
|
+
"skill_id": skill_key,
|
|
658
|
+
"name": skill_def.name,
|
|
659
|
+
"description": skill_description,
|
|
660
|
+
"template": skill_def.template,
|
|
661
|
+
"parameters": skill_params,
|
|
662
|
+
"triggers": skill_triggers,
|
|
663
|
+
"tools_required": skill_tools_required,
|
|
664
|
+
"enabled": skill_enabled,
|
|
665
|
+
},
|
|
666
|
+
)
|
|
667
|
+
except Exception as exc:
|
|
668
|
+
_die(exc)
|
|
669
|
+
return
|
|
670
|
+
console.print(
|
|
671
|
+
f" [green][created][/green] '{result['skill_id']}'"
|
|
672
|
+
f" name='{skill_def.name}' yaml_key='{skill_key}'"
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
console.print(f" Skillsets: created={ss_created} updated={ss_updated} skipped={ss_skipped}")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def export_cmd(config: str, dry_run: bool) -> None:
|
|
679
|
+
"""Export latest report versions and toolsets from the API back into *config* YAML."""
|
|
680
|
+
try:
|
|
681
|
+
existing_cfg = schema.load_file(config)
|
|
682
|
+
except FileNotFoundError:
|
|
683
|
+
err_console.print(f"[yellow][warn][/yellow] Config file '{config}' not found, starting from empty config.")
|
|
684
|
+
existing_cfg = schema.ReportingConfig()
|
|
685
|
+
|
|
686
|
+
name_to_key = {r.name: k for k, r in existing_cfg.reports.items()}
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
report_list = _list_reports()
|
|
690
|
+
except Exception as exc:
|
|
691
|
+
_die(exc)
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
dashboard_id: str | None = None
|
|
695
|
+
try:
|
|
696
|
+
dashboard_data = state.get_client().get("/api/v1/reports/dashboard")
|
|
697
|
+
dashboard_id = dashboard_data.get("report_id")
|
|
698
|
+
except APIError as exc:
|
|
699
|
+
if exc.status_code != 404:
|
|
700
|
+
err_console.print(f"[yellow][warn][/yellow] Could not fetch dashboard pointer: {exc}")
|
|
701
|
+
|
|
702
|
+
new_reports: dict[str, Any] = {}
|
|
703
|
+
dashboard_key: str | None = None
|
|
704
|
+
exported = failed = 0
|
|
705
|
+
|
|
706
|
+
for item in sorted(report_list, key=lambda r: r["name"]):
|
|
707
|
+
latest = _get_report(item["report_id"])
|
|
708
|
+
if not latest:
|
|
709
|
+
err_console.print(f"[yellow][warn][/yellow] No version found for '{item['name']}', skipping.")
|
|
710
|
+
failed += 1
|
|
711
|
+
continue
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
report_obj = schema.Report.model_validate(latest["config"])
|
|
715
|
+
except Exception as exc:
|
|
716
|
+
err_console.print(f"[yellow][warn][/yellow] Invalid config for '{item['name']}': {exc} — skipping.")
|
|
717
|
+
failed += 1
|
|
718
|
+
continue
|
|
719
|
+
|
|
720
|
+
key = name_to_key.get(item["name"]) or _slugify(item["name"])
|
|
721
|
+
base_key = key
|
|
722
|
+
suffix = 2
|
|
723
|
+
while key in new_reports and new_reports[key].name != item["name"]:
|
|
724
|
+
key = f"{base_key}-{suffix}"
|
|
725
|
+
suffix += 1
|
|
726
|
+
|
|
727
|
+
new_reports[key] = report_obj
|
|
728
|
+
if item.get("pinned"):
|
|
729
|
+
report_obj.pinned = True
|
|
730
|
+
if dashboard_id and item["report_id"] == dashboard_id:
|
|
731
|
+
dashboard_key = key
|
|
732
|
+
console.print(f"[green][export][/green] report '{item['name']}' → key='{key}'")
|
|
733
|
+
exported += 1
|
|
734
|
+
|
|
735
|
+
# Export toolsets
|
|
736
|
+
new_toolsets: dict[str, Any] = {}
|
|
737
|
+
ts_exported = ts_failed = 0
|
|
738
|
+
|
|
739
|
+
try:
|
|
740
|
+
toolset_list = _list_toolsets()
|
|
741
|
+
except Exception as exc:
|
|
742
|
+
_die(exc)
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
for ts_item in sorted(toolset_list, key=lambda t: t["name"]):
|
|
746
|
+
ts_key = ts_item["toolset_id"]
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
tools_data = _list_tools(ts_item["toolset_id"])
|
|
750
|
+
except Exception as exc:
|
|
751
|
+
err_console.print(
|
|
752
|
+
f"[yellow][warn][/yellow] Could not fetch tools for '{ts_item['name']}': {exc} — skipping toolset."
|
|
753
|
+
)
|
|
754
|
+
ts_failed += 1
|
|
755
|
+
continue
|
|
756
|
+
|
|
757
|
+
new_tools: dict[str, Any] = {}
|
|
758
|
+
for tool in sorted(tools_data, key=lambda t: t["name"]):
|
|
759
|
+
tool_key = tool["tool_id"]
|
|
760
|
+
params = [schema.ToolParamDef.model_validate(p) for p in tool.get("parameters", [])]
|
|
761
|
+
new_tools[tool_key] = schema.ToolDef(
|
|
762
|
+
name=tool["name"],
|
|
763
|
+
description=tool.get("description", ""),
|
|
764
|
+
cypher=tool["cypher"],
|
|
765
|
+
parameters=params,
|
|
766
|
+
enabled=tool.get("enabled", True),
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
new_toolsets[ts_key] = schema.ToolsetDef(
|
|
770
|
+
name=ts_item["name"],
|
|
771
|
+
description=ts_item.get("description", ""),
|
|
772
|
+
enabled=ts_item.get("enabled", True),
|
|
773
|
+
tools=new_tools,
|
|
774
|
+
)
|
|
775
|
+
console.print(f"[green][export][/green] toolset '{ts_item['name']}' ({len(new_tools)} tools) → key='{ts_key}'")
|
|
776
|
+
ts_exported += 1
|
|
777
|
+
|
|
778
|
+
# Export skillsets
|
|
779
|
+
new_skillsets: dict[str, Any] = {}
|
|
780
|
+
ss_exported = ss_failed = 0
|
|
781
|
+
|
|
782
|
+
try:
|
|
783
|
+
skillset_list = _list_skillsets()
|
|
784
|
+
except Exception as exc:
|
|
785
|
+
_die(exc)
|
|
786
|
+
return
|
|
787
|
+
|
|
788
|
+
for ss_item in sorted(skillset_list, key=lambda s: s["name"]):
|
|
789
|
+
ss_key = ss_item["skillset_id"]
|
|
790
|
+
try:
|
|
791
|
+
skills_data = _list_skills(ss_item["skillset_id"])
|
|
792
|
+
except Exception as exc:
|
|
793
|
+
err_console.print(
|
|
794
|
+
f"[yellow][warn][/yellow] Could not fetch skills for '{ss_item['name']}': {exc} — skipping skillset."
|
|
795
|
+
)
|
|
796
|
+
ss_failed += 1
|
|
797
|
+
continue
|
|
798
|
+
|
|
799
|
+
new_skills: dict[str, Any] = {}
|
|
800
|
+
for skill in sorted(skills_data, key=lambda s: s["name"]):
|
|
801
|
+
skill_key = skill["skill_id"]
|
|
802
|
+
params = [schema.ToolParamDef.model_validate(p) for p in skill.get("parameters", [])]
|
|
803
|
+
new_skills[skill_key] = schema.SkillDef(
|
|
804
|
+
name=skill["name"],
|
|
805
|
+
description=skill.get("description", ""),
|
|
806
|
+
template=skill["template"],
|
|
807
|
+
parameters=params,
|
|
808
|
+
triggers=skill.get("triggers", []),
|
|
809
|
+
tools_required=skill.get("tools_required", []),
|
|
810
|
+
enabled=skill.get("enabled", True),
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
new_skillsets[ss_key] = schema.SkillsetDef(
|
|
814
|
+
name=ss_item["name"],
|
|
815
|
+
description=ss_item.get("description", ""),
|
|
816
|
+
enabled=ss_item.get("enabled", True),
|
|
817
|
+
skills=new_skills,
|
|
818
|
+
)
|
|
819
|
+
console.print(
|
|
820
|
+
f"[green][export][/green] skillset '{ss_item['name']}' ({len(new_skills)} skills) → key='{ss_key}'"
|
|
821
|
+
)
|
|
822
|
+
ss_exported += 1
|
|
823
|
+
|
|
824
|
+
updated_cfg = schema.ReportingConfig(
|
|
825
|
+
queries=existing_cfg.queries,
|
|
826
|
+
dashboard=dashboard_key if dashboard_key is not None else existing_cfg.dashboard,
|
|
827
|
+
reports=new_reports,
|
|
828
|
+
scheduled_queries=existing_cfg.scheduled_queries,
|
|
829
|
+
toolsets=new_toolsets,
|
|
830
|
+
skillsets=new_skillsets,
|
|
831
|
+
)
|
|
832
|
+
yaml_content = schema.dump_yaml(updated_cfg)
|
|
833
|
+
|
|
834
|
+
if dry_run:
|
|
835
|
+
console.print("\n--- YAML output (dry-run, not written) ---\n")
|
|
836
|
+
console.print(yaml_content)
|
|
837
|
+
console.print(
|
|
838
|
+
f"\nDone. reports: exported={exported} failed={failed} "
|
|
839
|
+
f"toolsets: exported={ts_exported} failed={ts_failed} "
|
|
840
|
+
f"skillsets: exported={ss_exported} failed={ss_failed} "
|
|
841
|
+
"(dry-run, file not written)"
|
|
842
|
+
)
|
|
843
|
+
return
|
|
844
|
+
|
|
845
|
+
with open(config, "w") as f:
|
|
846
|
+
f.write(yaml_content)
|
|
847
|
+
console.print(
|
|
848
|
+
f"\nDone. reports: exported={exported} failed={failed} "
|
|
849
|
+
f"toolsets: exported={ts_exported} failed={ts_failed} "
|
|
850
|
+
f"skillsets: exported={ss_exported} failed={ss_failed} "
|
|
851
|
+
f"→ wrote '{config}'"
|
|
852
|
+
)
|