pactown 0.1.4__py3-none-any.whl → 0.1.47__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.
pactown/cli.py CHANGED
@@ -5,20 +5,49 @@ from pathlib import Path
5
5
  from typing import Optional
6
6
 
7
7
  import click
8
+ import yaml
8
9
  from rich.console import Console
9
10
  from rich.panel import Panel
10
- import yaml
11
11
 
12
12
  from . import __version__
13
- from .config import EcosystemConfig, load_config
14
- from .orchestrator import Orchestrator, run_ecosystem
13
+ from .config import load_config
14
+ from .generator import generate_config, print_scan_results
15
+ from .orchestrator import Orchestrator
15
16
  from .resolver import DependencyResolver
16
- from .generator import scan_folder, generate_config, print_scan_results
17
-
18
17
 
19
18
  console = Console()
20
19
 
21
20
 
21
+ def is_lolm_available() -> bool:
22
+ from .llm import is_lolm_available as _is_lolm_available
23
+
24
+ return _is_lolm_available()
25
+
26
+
27
+ def get_llm_status() -> dict:
28
+ from .llm import get_llm_status as _get_llm_status
29
+
30
+ return _get_llm_status()
31
+
32
+
33
+ def get_llm(*, verbose: bool = False):
34
+ from .llm import get_llm as _get_llm
35
+
36
+ return _get_llm(verbose=verbose)
37
+
38
+
39
+ def set_llm_priority(provider: str, priority: int) -> bool:
40
+ from .llm import set_provider_priority as _set_provider_priority
41
+
42
+ return bool(_set_provider_priority(provider, priority))
43
+
44
+
45
+ def reset_llm_provider(provider: str) -> bool:
46
+ from .llm import reset_provider as _reset_provider
47
+
48
+ return bool(_reset_provider(provider))
49
+
50
+
22
51
  @click.group()
23
52
  @click.version_option(version=__version__, prog_name="pactown")
24
53
  def cli():
@@ -38,12 +67,12 @@ def up(config_path: str, dry_run: bool, no_health: bool, quiet: bool, sequential
38
67
  try:
39
68
  config = load_config(config_path)
40
69
  orch = Orchestrator(config, base_path=Path(config_path).parent, verbose=not quiet)
41
-
70
+
42
71
  if dry_run:
43
72
  console.print(f"[bold]Dry run: {config.name}[/bold]\n")
44
73
  resolver = DependencyResolver(config)
45
74
  order = resolver.get_startup_order()
46
-
75
+
47
76
  console.print("Would start services in order:")
48
77
  for i, name in enumerate(order, 1):
49
78
  svc = config.services[name]
@@ -51,16 +80,16 @@ def up(config_path: str, dry_run: bool, no_health: bool, quiet: bool, sequential
51
80
  deps_str = f" (deps: {', '.join(deps)})" if deps else ""
52
81
  console.print(f" {i}. {name}:{svc.port}{deps_str}")
53
82
  return
54
-
83
+
55
84
  if not orch.validate():
56
85
  sys.exit(1)
57
-
86
+
58
87
  orch.start_all(
59
88
  wait_for_health=not no_health,
60
89
  parallel=not sequential,
61
90
  max_workers=workers,
62
91
  )
63
-
92
+
64
93
  console.print("\n[dim]Press Ctrl+C to stop all services[/dim]\n")
65
94
  try:
66
95
  while True:
@@ -69,7 +98,7 @@ def up(config_path: str, dry_run: bool, no_health: bool, quiet: bool, sequential
69
98
  except KeyboardInterrupt:
70
99
  console.print("\n[yellow]Shutting down...[/yellow]")
71
100
  orch.stop_all()
72
-
101
+
73
102
  except Exception as e:
74
103
  console.print(f"[red]Error: {e}[/red]")
75
104
  sys.exit(1)
@@ -109,7 +138,7 @@ def validate(config_path: str):
109
138
  try:
110
139
  config = load_config(config_path)
111
140
  orch = Orchestrator(config, base_path=Path(config_path).parent)
112
-
141
+
113
142
  if orch.validate():
114
143
  sys.exit(0)
115
144
  else:
@@ -165,11 +194,11 @@ def init(name: str, output: str):
165
194
  },
166
195
  },
167
196
  }
168
-
197
+
169
198
  output_path = Path(output)
170
199
  with open(output_path, "w") as f:
171
200
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
172
-
201
+
173
202
  console.print(f"[green]Created {output_path}[/green]")
174
203
  console.print("\nNext steps:")
175
204
  console.print(" 1. Create service README.md files")
@@ -184,10 +213,16 @@ def publish(config_path: str, registry: str):
184
213
  """Publish all modules to registry."""
185
214
  try:
186
215
  from .registry.client import RegistryClient
187
-
216
+
188
217
  config = load_config(config_path)
189
218
  client = RegistryClient(registry)
190
-
219
+
220
+ if not client.health():
221
+ console.print(
222
+ f"[red]Error: Registry not reachable at {registry}. Start it with: make registry[/red]"
223
+ )
224
+ sys.exit(1)
225
+
191
226
  for name, service in config.services.items():
192
227
  readme_path = Path(config_path).parent / service.readme
193
228
  if readme_path.exists():
@@ -213,10 +248,16 @@ def pull(config_path: str, registry: str):
213
248
  """Pull dependencies from registry."""
214
249
  try:
215
250
  from .registry.client import RegistryClient
216
-
251
+
217
252
  config = load_config(config_path)
218
253
  client = RegistryClient(registry)
219
-
254
+
255
+ if not client.health():
256
+ console.print(
257
+ f"[red]Error: Registry not reachable at {registry}. Start it with: make registry[/red]"
258
+ )
259
+ sys.exit(1)
260
+
220
261
  for name, service in config.services.items():
221
262
  for dep in service.depends_on:
222
263
  if dep.name not in config.services:
@@ -244,31 +285,32 @@ def scan(folder: str):
244
285
  @click.option("--base-port", "-p", default=8000, type=int, help="Starting port")
245
286
  def generate(folder: str, name: Optional[str], output: str, base_port: int):
246
287
  """Generate pactown config from a folder of README.md files.
247
-
288
+
248
289
  Example:
249
290
  pactown generate ./examples -o my-ecosystem.pactown.yaml
250
291
  """
251
292
  try:
252
293
  folder_path = Path(folder)
253
294
  output_path = Path(output)
254
-
295
+
255
296
  console.print(f"[bold]Scanning {folder_path}...[/bold]\n")
256
297
  print_scan_results(folder_path)
257
-
298
+
258
299
  console.print()
300
+
259
301
  config = generate_config(
260
302
  folder=folder_path,
261
303
  name=name,
262
304
  base_port=base_port,
263
305
  output=output_path,
264
306
  )
265
-
307
+
266
308
  console.print(f"\n[green]✓ Generated {output_path}[/green]")
267
309
  console.print(f" Services: {len(config['services'])}")
268
- console.print(f"\nNext steps:")
310
+ console.print("\nNext steps:")
269
311
  console.print(f" pactown validate {output}")
270
312
  console.print(f" pactown up {output}")
271
-
313
+
272
314
  except Exception as e:
273
315
  console.print(f"[red]Error: {e}[/red]")
274
316
  sys.exit(1)
@@ -282,24 +324,24 @@ def generate(folder: str, name: Optional[str], output: str, base_port: int):
282
324
  def deploy(config_path: str, output: str, production: bool, kubernetes: bool):
283
325
  """Generate deployment files (Docker Compose, Kubernetes)."""
284
326
  try:
327
+ from .deploy.base import DeploymentConfig
285
328
  from .deploy.compose import generate_compose_from_config
286
329
  from .deploy.kubernetes import KubernetesBackend
287
- from .deploy.base import DeploymentConfig
288
-
330
+
289
331
  config_path = Path(config_path)
290
332
  output_dir = Path(output)
291
333
  output_dir.mkdir(parents=True, exist_ok=True)
292
-
334
+
293
335
  if kubernetes:
294
336
  # Generate Kubernetes manifests
295
337
  from .config import load_config
296
338
  ecosystem = load_config(config_path)
297
339
  deploy_config = DeploymentConfig.for_production() if production else DeploymentConfig.for_development()
298
340
  k8s = KubernetesBackend(deploy_config)
299
-
341
+
300
342
  k8s_dir = output_dir / "kubernetes"
301
343
  k8s_dir.mkdir(exist_ok=True)
302
-
344
+
303
345
  for name, service in ecosystem.services.items():
304
346
  image = f"{deploy_config.image_prefix}/{name}:latest"
305
347
  manifests = k8s.generate_manifests(
@@ -311,9 +353,9 @@ def deploy(config_path: str, output: str, production: bool, kubernetes: bool):
311
353
  )
312
354
  k8s.save_manifests(name, manifests, k8s_dir)
313
355
  console.print(f" [green]✓[/green] {k8s_dir}/{name}.yaml")
314
-
356
+
315
357
  console.print(f"\n[green]Generated Kubernetes manifests in {k8s_dir}[/green]")
316
- console.print(f"\nDeploy with:")
358
+ console.print("\nDeploy with:")
317
359
  console.print(f" kubectl apply -f {k8s_dir}/")
318
360
  else:
319
361
  # Generate Docker Compose
@@ -322,15 +364,15 @@ def deploy(config_path: str, output: str, production: bool, kubernetes: bool):
322
364
  output_dir=output_dir,
323
365
  production=production,
324
366
  )
325
-
367
+
326
368
  console.print(f"\n[green]Generated Docker Compose files in {output_dir}[/green]")
327
- console.print(f"\nRun with:")
369
+ console.print("\nRun with:")
328
370
  if production:
329
- console.print(f" docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d")
371
+ console.print(" docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d")
330
372
  else:
331
- console.print(f" docker compose up -d")
332
- console.print(f" # or: podman-compose up -d")
333
-
373
+ console.print(" docker compose up -d")
374
+ console.print(" # or: podman-compose up -d")
375
+
334
376
  except Exception as e:
335
377
  console.print(f"[red]Error: {e}[/red]")
336
378
  import traceback
@@ -338,6 +380,466 @@ def deploy(config_path: str, output: str, production: bool, kubernetes: bool):
338
380
  sys.exit(1)
339
381
 
340
382
 
383
+ @cli.group()
384
+ def quadlet():
385
+ """Podman Quadlet deployment commands for VPS production."""
386
+ pass
387
+
388
+
389
+ @quadlet.command("shell")
390
+ @click.option("--tenant", "-t", default="default", help="Tenant ID")
391
+ @click.option("--domain", "-d", default="localhost", help="Base domain")
392
+ @click.option("--system", is_flag=True, help="Use system-wide systemd (requires root)")
393
+ def quadlet_shell(tenant: str, domain: str, system: bool):
394
+ """Start interactive Quadlet deployment shell.
395
+
396
+ Example:
397
+ pactown quadlet shell --domain pactown.com --tenant user01
398
+ """
399
+ from .deploy.quadlet_shell import run_shell
400
+ run_shell(tenant_id=tenant, domain=domain, user_mode=not system)
401
+
402
+
403
+ @quadlet.command("api")
404
+ @click.option("--host", "-h", default="0.0.0.0", help="API host")
405
+ @click.option("--port", "-p", default=8800, type=int, help="API port")
406
+ @click.option("--domain", "-d", default="localhost", help="Default domain")
407
+ @click.option("--tenant", "-t", default="default", help="Default tenant")
408
+ def quadlet_api(host: str, port: int, domain: str, tenant: str):
409
+ """Start Quadlet API server for programmatic deployments.
410
+
411
+ Example:
412
+ pactown quadlet api --port 8800 --domain pactown.com
413
+ """
414
+ from .deploy.quadlet_api import run_api
415
+ console.print("[bold]Starting Quadlet API server...[/bold]")
416
+ console.print(f" Host: {host}:{port}")
417
+ console.print(f" Domain: {domain}")
418
+ console.print(f" Docs: http://{host}:{port}/docs")
419
+ run_api(host=host, port=port, domain=domain, tenant=tenant)
420
+
421
+
422
+ @quadlet.command("generate")
423
+ @click.argument("markdown_path", type=click.Path(exists=True))
424
+ @click.option("--output", "-o", default=".", help="Output directory")
425
+ @click.option("--domain", "-d", default="localhost", help="Domain")
426
+ @click.option("--subdomain", "-s", help="Subdomain")
427
+ @click.option("--tenant", "-t", default="default", help="Tenant ID")
428
+ @click.option("--tls/--no-tls", default=False, help="Enable TLS")
429
+ def quadlet_generate(markdown_path: str, output: str, domain: str, subdomain: str, tenant: str, tls: bool):
430
+ """Generate Quadlet files for a Markdown service.
431
+
432
+ Example:
433
+ pactown quadlet generate ./README.md --domain pactown.com --subdomain docs
434
+ """
435
+ from .deploy.quadlet import QuadletConfig, generate_markdown_service_quadlet
436
+
437
+ config = QuadletConfig(
438
+ tenant_id=tenant,
439
+ domain=domain,
440
+ subdomain=subdomain,
441
+ tls_enabled=tls,
442
+ )
443
+
444
+ units = generate_markdown_service_quadlet(
445
+ markdown_path=Path(markdown_path).resolve(),
446
+ config=config,
447
+ )
448
+
449
+ output_dir = Path(output)
450
+ output_dir.mkdir(parents=True, exist_ok=True)
451
+
452
+ for unit in units:
453
+ path = output_dir / unit.filename
454
+ path.write_text(unit.content)
455
+ console.print(f"[green]✓ Generated: {path}[/green]")
456
+
457
+ console.print("\n[bold]Deploy with:[/bold]")
458
+ console.print(f" cp {output}/*.container ~/.config/containers/systemd/tenant-{tenant}/")
459
+ console.print(" systemctl --user daemon-reload")
460
+ console.print(f" systemctl --user enable --now {units[0].name}.service")
461
+
462
+
463
+ @quadlet.command("init")
464
+ @click.option("--domain", "-d", required=True, help="Domain for Traefik")
465
+ @click.option("--email", "-e", help="Email for Let's Encrypt")
466
+ @click.option("--system", is_flag=True, help="Use system-wide systemd")
467
+ def quadlet_init(domain: str, email: str, system: bool):
468
+ """Initialize Quadlet environment with Traefik.
469
+
470
+ Example:
471
+ pactown quadlet init --domain pactown.com --email admin@pactown.com
472
+ """
473
+ from .deploy.quadlet import QuadletConfig, generate_traefik_quadlet
474
+
475
+ config = QuadletConfig(domain=domain, user_mode=not system)
476
+
477
+ # Create directories
478
+ config.systemd_path.mkdir(parents=True, exist_ok=True)
479
+ console.print(f"[green]✓ Created: {config.systemd_path}[/green]")
480
+
481
+ # Generate Traefik
482
+ units = generate_traefik_quadlet(config)
483
+
484
+ for unit in units:
485
+ content = unit.content
486
+ if email:
487
+ content = content.replace(f"admin@{domain}", email)
488
+
489
+ path = config.systemd_path / unit.filename
490
+ path.write_text(content)
491
+ console.print(f"[green]✓ Generated: {path}[/green]")
492
+
493
+ console.print("\n[bold]Start Traefik:[/bold]")
494
+ mode = "" if system else " --user"
495
+ console.print(f" systemctl{mode} daemon-reload")
496
+ console.print(f" systemctl{mode} enable --now traefik.service")
497
+
498
+
499
+ @quadlet.command("deploy")
500
+ @click.argument("markdown_path", type=click.Path(exists=True))
501
+ @click.option("--domain", "-d", required=True, help="Domain")
502
+ @click.option("--subdomain", "-s", help="Subdomain")
503
+ @click.option("--tenant", "-t", default="default", help="Tenant ID")
504
+ @click.option("--tls/--no-tls", default=True, help="Enable TLS")
505
+ @click.option("--image", default="ghcr.io/pactown/markdown-server:latest", help="Container image")
506
+ def quadlet_deploy(markdown_path: str, domain: str, subdomain: str, tenant: str, tls: bool, image: str):
507
+ """Deploy a Markdown file to VPS using Quadlet.
508
+
509
+ Example:
510
+ pactown quadlet deploy ./README.md --domain pactown.com --subdomain docs --tls
511
+ """
512
+ from .deploy.base import DeploymentConfig
513
+ from .deploy.quadlet import QuadletBackend, QuadletConfig, generate_markdown_service_quadlet
514
+
515
+ config = QuadletConfig(
516
+ tenant_id=tenant,
517
+ domain=domain,
518
+ subdomain=subdomain,
519
+ tls_enabled=tls,
520
+ )
521
+
522
+ deploy_config = DeploymentConfig.for_production()
523
+ backend = QuadletBackend(deploy_config, config)
524
+
525
+ if not backend.is_available():
526
+ console.print("[red]✗ Podman 4.4+ with Quadlet support not available[/red]")
527
+ sys.exit(1)
528
+
529
+ md_path = Path(markdown_path).resolve()
530
+ console.print(f"[bold]Deploying: {md_path.name}[/bold]")
531
+ console.print(f" Domain: {config.full_domain}")
532
+ console.print(f" Tenant: {tenant}")
533
+ console.print(f" TLS: {tls}")
534
+
535
+ # Generate units
536
+ units = generate_markdown_service_quadlet(md_path, config, image)
537
+
538
+ # Save to tenant path
539
+ config.tenant_path.mkdir(parents=True, exist_ok=True)
540
+ for unit in units:
541
+ unit.save(config.tenant_path)
542
+ console.print(f"[dim]Created: {unit.filename}[/dim]")
543
+
544
+ # Reload and start
545
+ backend._systemctl("daemon-reload")
546
+ service = f"{units[0].name}.service"
547
+ backend._systemctl("enable", service)
548
+ result = backend._systemctl("start", service)
549
+
550
+ if result.returncode == 0:
551
+ url = f"https://{config.full_domain}" if tls else f"http://{config.full_domain}"
552
+ console.print("\n[green]✓ Deployed successfully![/green]")
553
+ console.print(f" URL: {url}")
554
+ else:
555
+ console.print(f"\n[red]✗ Deployment failed: {result.stderr}[/red]")
556
+ sys.exit(1)
557
+
558
+
559
+ @quadlet.command("list")
560
+ @click.option("--tenant", "-t", default="default", help="Tenant ID")
561
+ def quadlet_list(tenant: str):
562
+ """List all Quadlet services for a tenant.
563
+
564
+ Example:
565
+ pactown quadlet list --tenant user01
566
+ """
567
+ from rich.table import Table
568
+
569
+ from .deploy.base import DeploymentConfig
570
+ from .deploy.quadlet import QuadletBackend, QuadletConfig
571
+
572
+ config = QuadletConfig(tenant_id=tenant)
573
+ backend = QuadletBackend(DeploymentConfig.for_production(), config)
574
+
575
+ services = backend.list_services()
576
+
577
+ if not services:
578
+ console.print(f"[yellow]No services found for tenant: {tenant}[/yellow]")
579
+ return
580
+
581
+ table = Table(title=f"Services (tenant: {tenant})")
582
+ table.add_column("Name", style="cyan")
583
+ table.add_column("Status", style="green")
584
+ table.add_column("State")
585
+
586
+ for svc in services:
587
+ status = svc["status"]
588
+ running = "🟢 running" if status.get("running") else "🔴 stopped"
589
+ table.add_row(svc["name"], running, status.get("state", "-"))
590
+
591
+ console.print(table)
592
+
593
+
594
+ @quadlet.command("logs")
595
+ @click.argument("service_name")
596
+ @click.option("--tenant", "-t", default="default", help="Tenant ID")
597
+ @click.option("--lines", "-n", default=50, type=int, help="Number of lines")
598
+ def quadlet_logs(service_name: str, tenant: str, lines: int):
599
+ """Show logs for a Quadlet service.
600
+
601
+ Example:
602
+ pactown quadlet logs my-service --lines 100
603
+ """
604
+ from .deploy.base import DeploymentConfig
605
+ from .deploy.quadlet import QuadletBackend, QuadletConfig
606
+
607
+ config = QuadletConfig(tenant_id=tenant)
608
+ backend = QuadletBackend(DeploymentConfig.for_production(), config)
609
+
610
+ output = backend.logs(service_name, tail=lines)
611
+ console.print(output or "[dim]No logs available[/dim]")
612
+
613
+
614
+ @cli.group()
615
+ def llm():
616
+ """LLM provider management with rotation and fallback."""
617
+ pass
618
+
619
+
620
+ @llm.command("status")
621
+ def llm_status():
622
+ """Show status of all LLM providers.
623
+
624
+ Example:
625
+ pactown llm status
626
+ """
627
+ import sys
628
+
629
+ status = get_llm_status()
630
+
631
+ if not status.get("lolm_installed", False):
632
+ console.print("[yellow]lolm library not available[/yellow]")
633
+ if status.get("lolm_import_error"):
634
+ console.print(f"Import error: {status['lolm_import_error']}")
635
+ console.print("Install/upgrade (same interpreter as pactown):")
636
+ console.print(f" {sys.executable} -m pip install -U pactown[llm]\n", markup=False)
637
+ console.print("Or install directly:")
638
+ console.print(f" {sys.executable} -m pip install -U lolm")
639
+ return
640
+
641
+ if not status.get('is_available'):
642
+ console.print("[yellow]No LLM providers available[/yellow]")
643
+ if status.get("lolm_version"):
644
+ console.print(f"lolm version: {status['lolm_version']}")
645
+ if status.get("rotation_available") is False and status.get("rotation_import_error"):
646
+ console.print(f"Rotation not available: {status['rotation_import_error']}")
647
+ if 'error' in status:
648
+ console.print(f"Error: {status['error']}")
649
+ console.print("\n[dim]Tip: run `pactown llm doctor` to check Python/pip mismatch[/dim]")
650
+ return
651
+
652
+ console.print("[bold]LLM Provider Status[/bold]\n")
653
+
654
+ if status.get("lolm_version"):
655
+ console.print(f"[dim]lolm version: {status['lolm_version']}[/dim]")
656
+ if status.get("rotation_available") is False:
657
+ console.print("[dim]rotation: not available (fallback only)[/dim]")
658
+ elif status.get("rotation_available") is True:
659
+ console.print("[dim]rotation: enabled[/dim]")
660
+ console.print()
661
+
662
+ providers = status.get('providers', {})
663
+ for name, info in providers.items():
664
+ state = info.get('status', 'unknown')
665
+ model = info.get('model', '')
666
+ priority = info.get('priority', 100)
667
+
668
+ if state == 'available':
669
+ state_icon = "[green]●[/green]"
670
+ elif state == 'unavailable':
671
+ state_icon = "[red]○[/red]"
672
+ else:
673
+ state_icon = "[yellow]◐[/yellow]"
674
+
675
+ console.print(f" {state_icon} [bold]{name}[/bold] ({model})")
676
+ console.print(f" Priority: {priority}")
677
+
678
+ health = info.get('health', {})
679
+ if health:
680
+ success_rate = health.get('success_rate', 1.0)
681
+ total = health.get('total_requests', 0)
682
+ rate_limits = health.get('rate_limit_hits', 0)
683
+ console.print(f" Requests: {total} (success: {success_rate:.1%})")
684
+ if rate_limits > 0:
685
+ console.print(f" [yellow]Rate limits: {rate_limits}[/yellow]")
686
+
687
+ if info.get('error'):
688
+ console.print(f" [red]Error: {info['error']}[/red]")
689
+
690
+ console.print()
691
+
692
+
693
+ @llm.command("doctor")
694
+ def llm_doctor():
695
+ """Diagnose LLM installation and environment issues.
696
+
697
+ Helps detect situations where `pactown` is executed with a different
698
+ Python interpreter than the one where you installed `lolm`.
699
+
700
+ Example:
701
+ pactown llm doctor
702
+ """
703
+ import importlib.util
704
+ import platform
705
+ import subprocess
706
+ import shutil
707
+ import sys
708
+
709
+ from . import __version__
710
+ from . import llm as llm_mod
711
+
712
+ console.print("[bold]LLM Doctor[/bold]\n")
713
+
714
+ console.print("[bold]Runtime[/bold]")
715
+ console.print(f" Python: {sys.executable}")
716
+ console.print(f" Python version: {platform.python_version()}")
717
+ console.print(f" pactown version: {__version__}")
718
+
719
+ pactown_spec = importlib.util.find_spec("pactown")
720
+ if pactown_spec and pactown_spec.origin:
721
+ console.print(f" pactown module: {pactown_spec.origin}")
722
+
723
+ try:
724
+ pip_v = subprocess.check_output(
725
+ [sys.executable, "-m", "pip", "-V"],
726
+ stderr=subprocess.STDOUT,
727
+ text=True,
728
+ ).strip()
729
+ console.print(f" pip: {pip_v}")
730
+ except Exception as e:
731
+ console.print(f" pip: [red]error[/red] ({e})")
732
+
733
+ pip_on_path = shutil.which("pip")
734
+ if pip_on_path:
735
+ console.print(f" pip (PATH): {pip_on_path}")
736
+ else:
737
+ console.print(" pip (PATH): [dim]not found[/dim]")
738
+
739
+ console.print("\n[bold]lolm[/bold]")
740
+ info = llm_mod.get_lolm_info()
741
+ console.print(f" installed: {info.get('lolm_installed')}")
742
+ if info.get("lolm_version"):
743
+ console.print(f" version: {info['lolm_version']}")
744
+
745
+ lolm_spec = importlib.util.find_spec("lolm")
746
+ if lolm_spec and lolm_spec.origin:
747
+ console.print(f" module: {lolm_spec.origin}")
748
+
749
+ if info.get("lolm_import_error"):
750
+ console.print(f" import error: {info['lolm_import_error']}")
751
+
752
+ console.print("\n[bold]Rotation[/bold]")
753
+ console.print(f" available: {info.get('rotation_available')}")
754
+ if info.get("rotation_import_error"):
755
+ console.print(f" error: {info['rotation_import_error']}")
756
+
757
+ console.print("\n[bold]Suggested fix[/bold]")
758
+ if not info.get("lolm_installed"):
759
+ console.print(" python -m pip install -U 'pactown[llm]'", markup=False)
760
+ console.print(f" {sys.executable} -m pip install -U 'pactown[llm]'", markup=False)
761
+ console.print(f" {sys.executable} -m pip install -U lolm")
762
+ elif not info.get("rotation_available"):
763
+ console.print(f" {sys.executable} -m pip install -U lolm")
764
+ console.print(" # rotation will be enabled automatically when supported")
765
+ else:
766
+ console.print(" OK")
767
+
768
+
769
+ @llm.command("priority")
770
+ @click.argument("provider")
771
+ @click.argument("priority", type=int)
772
+ def llm_priority(provider: str, priority: int):
773
+ """Set priority for an LLM provider (lower = higher priority).
774
+
775
+ Example:
776
+ pactown llm priority openrouter 10
777
+ pactown llm priority groq 20
778
+ """
779
+ if not is_lolm_available():
780
+ console.print("[yellow]lolm library not installed[/yellow]")
781
+ return
782
+
783
+ if set_llm_priority(provider, priority):
784
+ console.print(f"[green]✓ Set {provider} priority to {priority}[/green]")
785
+ else:
786
+ console.print(f"[red]Failed to set priority for {provider}[/red]")
787
+
788
+
789
+ @llm.command("reset")
790
+ @click.argument("provider")
791
+ def llm_reset(provider: str):
792
+ """Reset an LLM provider's health metrics.
793
+
794
+ Clears failure counts, rate limit history, and cooldowns.
795
+
796
+ Example:
797
+ pactown llm reset groq
798
+ """
799
+ if not is_lolm_available():
800
+ console.print("[yellow]lolm library not installed[/yellow]")
801
+ return
802
+
803
+ if reset_llm_provider(provider):
804
+ console.print(f"[green]✓ Reset {provider} health metrics[/green]")
805
+ else:
806
+ console.print(f"[red]Failed to reset {provider}[/red]")
807
+
808
+
809
+ @llm.command("test")
810
+ @click.option("--provider", "-p", help="Specific provider to test")
811
+ @click.option("--rotation", "-r", is_flag=True, help="Test with rotation")
812
+ def llm_test(provider: str, rotation: bool):
813
+ """Test LLM generation with a simple prompt.
814
+
815
+ Example:
816
+ pactown llm test
817
+ pactown llm test --provider openrouter
818
+ pactown llm test --rotation
819
+ """
820
+ if not is_lolm_available():
821
+ console.print("[yellow]lolm library not installed[/yellow]")
822
+ return
823
+
824
+ try:
825
+ llm = get_llm()
826
+ prompt = "Say 'Hello from Pactown!' in one short sentence."
827
+
828
+ console.print("[dim]Testing LLM generation...[/dim]")
829
+
830
+ if rotation:
831
+ response = llm.generate_with_rotation(prompt, max_tokens=50)
832
+ elif provider:
833
+ response = llm.generate(prompt, provider=provider, max_tokens=50)
834
+ else:
835
+ response = llm.generate(prompt, max_tokens=50)
836
+
837
+ console.print(f"[green]✓ Response:[/green] {response}")
838
+
839
+ except Exception as e:
840
+ console.print(f"[red]Error: {e}[/red]")
841
+
842
+
341
843
  def main(argv=None):
342
844
  """Main entry point."""
343
845
  cli(argv)