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.
@@ -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
+ )