flexgate 0.1.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.
flexgate/cli.py ADDED
@@ -0,0 +1,935 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ import re
8
+
9
+ try:
10
+ import curses
11
+ except ImportError: # pragma: no cover - curses is unavailable on some platforms
12
+ curses = None # type: ignore[assignment]
13
+
14
+ from flexgate.config import (
15
+ GatewayConfig,
16
+ RouteConfig,
17
+ CURRENT_CONFIG_VERSION,
18
+ DEFAULT_CONFIG_TEMPLATE,
19
+ TIER_PATTERNS,
20
+ get_default_config_path,
21
+ ensure_home_dir,
22
+ load_config,
23
+ save_config,
24
+ )
25
+ from flexgate import __version__
26
+ from flexgate.healthcheck import check_providers, print_results
27
+ from flexgate.main import run_server
28
+
29
+ PATTERN_TIERS = {v: k for k, v in TIER_PATTERNS.items()}
30
+
31
+ # ── run / check (foreground debugging) ─────────────────────────────
32
+
33
+ def _service_config_arg(args: argparse.Namespace) -> str | None:
34
+ if getattr(args, "config_explicit", False):
35
+ return args.config
36
+ return None
37
+
38
+
39
+ def _print_active_routes(config: GatewayConfig) -> None:
40
+ from datetime import datetime
41
+
42
+ now = datetime.now()
43
+ now_minutes = now.hour * 60 + now.minute
44
+
45
+ active_routes = None
46
+ label = "default"
47
+
48
+ for entry in config.schedule:
49
+ s, e = entry.start_minutes, entry.end_minutes
50
+ if s < e:
51
+ active = s <= now_minutes < e
52
+ elif s > e:
53
+ active = now_minutes >= s or now_minutes < e
54
+ else:
55
+ active = True
56
+ if active and entry.routes:
57
+ active_routes = entry.routes
58
+ label = entry.name or f"{s // 60:02d}:{s % 60:02d}-{e // 60:02d}:{e % 60:02d}"
59
+ break
60
+
61
+ if active_routes is None:
62
+ active_routes = config.routes
63
+
64
+ if not active_routes:
65
+ return
66
+
67
+ print(f"\nRoutes ({label}):")
68
+ _print_route_table(active_routes)
69
+
70
+
71
+ def cmd_run(args: argparse.Namespace) -> None:
72
+ run_server(args.config, args.port)
73
+
74
+
75
+ def cmd_check(args: argparse.Namespace) -> None:
76
+ config = load_config(args.config)
77
+ timeout = getattr(args, "verify_timeout", 15.0)
78
+ print(f"Verifying provider connectivity (timeout {timeout:g}s)...")
79
+ results = check_providers(config, timeout=timeout)
80
+ print_results(results)
81
+ failures = [r for r in results if not r.ok]
82
+ if failures:
83
+ print(f"\n{len(failures)} target(s) failed.")
84
+ sys.exit(1)
85
+ print(f"\nAll {len(results)} target(s) OK.")
86
+
87
+
88
+ # ── settings subcommands ────────────────────────────────────────────
89
+
90
+ def cmd_settings_import(args: argparse.Namespace) -> None:
91
+ from flexgate.settings import settings_import
92
+ settings_import(args.config)
93
+
94
+
95
+ def cmd_settings_apply(args: argparse.Namespace) -> None:
96
+ from flexgate.settings import settings_apply
97
+ settings_apply(args.config)
98
+
99
+
100
+ # ── sync subcommand ────────────────────────────────────────────────
101
+
102
+ def cmd_sync(args: argparse.Namespace) -> None:
103
+ from flexgate.sync import sync_pull
104
+ sync_pull(args.config, dry_run=getattr(args, "dry_run", False))
105
+
106
+
107
+ # ── config subcommands ────────────────────────────────────────────
108
+
109
+ def _mask_key(key: str) -> str:
110
+ if len(key) <= 8:
111
+ return "***"
112
+ return key[:4] + "***" + key[-4:]
113
+
114
+
115
+ def _print_route_table(routes: list[RouteConfig], indent: int = 2) -> None:
116
+ prefix = " " * indent
117
+ for r in routes:
118
+ tier = PATTERN_TIERS.get(r.pattern.pattern, "")
119
+ tier_col = f"({tier}) " if tier else ""
120
+ target = r.provider_name
121
+ if r.model:
122
+ target += f" / {r.model}"
123
+ print(f"{prefix}{tier_col}{r.pattern.pattern:20s} → {target}")
124
+
125
+
126
+ def _set_tier_route(
127
+ config: GatewayConfig, tier_name: str, provider_name: str, model_override: str | None
128
+ ) -> None:
129
+ """Update the default route for a tier, or insert one before the catch-all."""
130
+ pattern = TIER_PATTERNS[tier_name]
131
+ for route in config.routes:
132
+ if route.pattern.pattern == pattern:
133
+ route.provider_name = provider_name
134
+ route.model = model_override
135
+ return
136
+
137
+ new_route = RouteConfig(
138
+ pattern=re.compile(pattern),
139
+ provider_name=provider_name,
140
+ model=model_override,
141
+ )
142
+ # Insert before the catch-all (".*") if present, else append.
143
+ for i, route in enumerate(config.routes):
144
+ if route.pattern.pattern == ".*":
145
+ config.routes.insert(i, new_route)
146
+ return
147
+ config.routes.append(new_route)
148
+
149
+
150
+ def _signal_reload(config_path: str) -> str | None:
151
+ """Reload the active systemd service, if one is running."""
152
+ from flexgate.service import reload_service_if_active
153
+
154
+ return reload_service_if_active(config_path)
155
+
156
+
157
+ def _hot_reload(config_path: str) -> None:
158
+ """Ask the active systemd service to reload its config."""
159
+ msg = _signal_reload(config_path)
160
+ if msg:
161
+ print(msg)
162
+
163
+
164
+ def cmd_config_init(args: argparse.Namespace) -> None:
165
+ config_path = args.config
166
+ if os.path.exists(config_path):
167
+ print(f"Config already exists: {config_path}")
168
+ return
169
+ with open(config_path, "w") as f:
170
+ f.write(f"config_version: {CURRENT_CONFIG_VERSION}\n\n")
171
+ f.write(DEFAULT_CONFIG_TEMPLATE)
172
+ print(f"Created: {config_path}")
173
+ print("Edit the file to add your API keys and providers.")
174
+
175
+
176
+ def cmd_config_show(args: argparse.Namespace) -> None:
177
+ from datetime import datetime
178
+
179
+ config_path = args.config
180
+ if not os.path.exists(config_path):
181
+ print(f"Config not found: {config_path}")
182
+ print("Run 'flexgate config init' to create one.")
183
+ return
184
+
185
+ config = load_config(config_path)
186
+
187
+ print(f"Config: {os.path.abspath(config_path)}")
188
+ print(f"Server: {config.server.host}:{config.server.port}")
189
+
190
+ print(f"\nProviders:")
191
+ for name, prov in config.providers.items():
192
+ print(f" {name:16s} {prov.base_url} (key: {_mask_key(prov.api_key)})")
193
+
194
+ print(f"\nRoutes (default):")
195
+ _print_route_table(config.routes)
196
+
197
+ if config.schedule:
198
+ now = datetime.now()
199
+ now_min = now.hour * 60 + now.minute
200
+ print(f"\nSchedule:")
201
+ for entry in config.schedule:
202
+ s, e = entry.start_minutes, entry.end_minutes
203
+ start_s = f"{s // 60:02d}:{s % 60:02d}"
204
+ end_s = f"{e // 60:02d}:{e % 60:02d}"
205
+ if s < e:
206
+ active = s <= now_min < e
207
+ elif s > e:
208
+ active = now_min >= s or now_min < e
209
+ else:
210
+ active = True
211
+ label = entry.name or f"{start_s}-{end_s}"
212
+ marker = " ← active" if active else ""
213
+ print(f" {label} ({start_s}-{end_s}){marker}:")
214
+ _print_route_table(entry.routes, indent=4)
215
+
216
+
217
+ def cmd_config_set(args: argparse.Namespace) -> None:
218
+ tier = args.tier.lower()
219
+ target = args.target
220
+ model_arg = args.model
221
+
222
+ if tier == "all":
223
+ tiers = list(TIER_PATTERNS)
224
+ else:
225
+ tiers = [t.strip() for t in tier.split(",") if t.strip()]
226
+ unknown = [t for t in tiers if t not in TIER_PATTERNS]
227
+ if not tiers or unknown:
228
+ bad = unknown[0] if unknown else tier
229
+ print(f"Unknown tier '{bad}'.")
230
+ print(f"Available: all, {', '.join(TIER_PATTERNS)}")
231
+ print("Combine multiple tiers with commas, e.g. opus,sonnet")
232
+ sys.exit(1)
233
+ # De-duplicate while preserving order
234
+ seen_tiers: set[str] = set()
235
+ tiers = [t for t in tiers if not (t in seen_tiers or seen_tiers.add(t))]
236
+
237
+ config_path = args.config
238
+
239
+ if not os.path.exists(config_path):
240
+ print(f"Config not found: {config_path}")
241
+ print("Run 'flexgate config init' first.")
242
+ sys.exit(1)
243
+
244
+ config = load_config(config_path)
245
+
246
+ provider_name: str | None = None
247
+ model_override: str | None = model_arg
248
+
249
+ if target in config.providers:
250
+ # Target is a known provider name
251
+ provider_name = target
252
+ else:
253
+ if model_arg is not None:
254
+ # model arg given but target isn't a known provider
255
+ print(f"Provider '{target}' not found.")
256
+ print(f"Known providers: {', '.join(config.providers)}")
257
+ sys.exit(1)
258
+
259
+ # Try to resolve target as a model name from existing routes
260
+ all_routes = list(config.routes)
261
+ for entry in config.schedule:
262
+ all_routes.extend(entry.routes)
263
+
264
+ matches: list[tuple[str, str]] = []
265
+ seen: set[tuple[str, str]] = set()
266
+ for route in all_routes:
267
+ if route.model and route.model.lower() == target.lower():
268
+ key = (route.provider_name, route.model)
269
+ if key not in seen:
270
+ seen.add(key)
271
+ matches.append(key)
272
+
273
+ if len(matches) == 1:
274
+ provider_name, model_override = matches[0]
275
+ elif len(matches) > 1:
276
+ print(f"Ambiguous: '{target}' found in multiple providers:")
277
+ for prov, mdl in sorted(matches):
278
+ print(f" flexgate config set {tier} {prov} {mdl}")
279
+ sys.exit(1)
280
+ else:
281
+ print(f"Unknown target '{target}'.")
282
+ print(f"Not a known provider or model name.\n")
283
+ print(f"Known providers: {', '.join(config.providers)}")
284
+ known_models = sorted({r.model for r in all_routes if r.model})
285
+ if known_models:
286
+ print(f"Known models: {', '.join(known_models)}")
287
+ print(f"\nUsage: flexgate config set {tier} <provider> [model]")
288
+ sys.exit(1)
289
+
290
+ if provider_name not in config.providers:
291
+ print(f"Provider '{provider_name}' not found in config.")
292
+ print(f"Known providers: {', '.join(config.providers)}")
293
+ print(f"\nAdd it to {config_path} first (base_url and api_key required).")
294
+ sys.exit(1)
295
+
296
+ provider = config.providers[provider_name]
297
+ if model_override is None and not provider.available_models:
298
+ print(f"Provider '{provider_name}' has no 'available_models' to fall back to.")
299
+ print(f"Either add 'available_models' to provider '{provider_name}' in {config_path},")
300
+ print(f"or specify a model: flexgate config set {tier} {provider_name} <model>")
301
+ sys.exit(1)
302
+
303
+ # Update existing route or insert new one for each target tier
304
+ for tier_name in tiers:
305
+ _set_tier_route(config, tier_name, provider_name, model_override)
306
+
307
+ save_config(config, config_path)
308
+
309
+ display = provider_name
310
+ if model_override:
311
+ display += f" / {model_override}"
312
+ elif provider.available_models:
313
+ display += f" / {provider.available_models[0]} (fallback)"
314
+ for tier_name in tiers:
315
+ print(f"Set {tier_name} ({TIER_PATTERNS[tier_name]}) → {display}")
316
+ print(f"Saved: {config_path}")
317
+
318
+ # Hot-reload the authoritative systemd service, if it is active.
319
+ _hot_reload(config_path)
320
+
321
+
322
+ def cmd_config_path(args: argparse.Namespace) -> None:
323
+ print(args.config)
324
+
325
+
326
+ # ── config edit (interactive arrow-key TUI) ───────────────────────
327
+
328
+ _CANCEL = object() # sentinel: user backed out of a menu
329
+ _CUSTOM = object() # sentinel: user chose "enter a custom model"
330
+
331
+
332
+ def _tier_current(config: GatewayConfig, tier_name: str) -> tuple[str | None, str | None]:
333
+ """Return (provider_name, model) for a tier's default route, or (None, None)."""
334
+ pattern = TIER_PATTERNS[tier_name]
335
+ for route in config.routes:
336
+ if route.pattern.pattern == pattern:
337
+ return route.provider_name, route.model
338
+ return None, None
339
+
340
+
341
+ def _format_target(provider_name: str | None, model: str | None) -> str:
342
+ if provider_name is None:
343
+ return "(unset)"
344
+ if model:
345
+ return f"{provider_name} / {model}"
346
+ return f"{provider_name} / (provider default)"
347
+
348
+
349
+ # ── curses drawing primitives ─────────────────────────────────────
350
+
351
+ def _addline(stdscr, y: int, x: int, text: str, attr: int = 0) -> None:
352
+ h, w = stdscr.getmaxyx()
353
+ if 0 <= y < h and x < w:
354
+ try:
355
+ stdscr.addnstr(y, x, text, max(0, w - 1 - x), attr)
356
+ except Exception:
357
+ pass
358
+
359
+
360
+ def _menu_draw(stdscr, header_lines, option_labels, index, footer_lines=()) -> None:
361
+ stdscr.erase()
362
+ h, _ = stdscr.getmaxyx()
363
+
364
+ y = 0
365
+ for line in header_lines:
366
+ _addline(stdscr, y, 0, line, curses.A_BOLD if y == 0 else curses.A_NORMAL)
367
+ y += 1
368
+
369
+ footer_h = len(footer_lines)
370
+ list_top = y
371
+ visible = max(1, h - footer_h - list_top)
372
+ n = len(option_labels)
373
+ if n <= visible:
374
+ offset = 0
375
+ else:
376
+ offset = min(max(0, index - visible // 2), n - visible)
377
+
378
+ yy = list_top
379
+ for i in range(offset, min(n, offset + visible)):
380
+ selected = i == index
381
+ marker = "▶ " if selected else " "
382
+ attr = (curses.A_REVERSE | curses.A_BOLD) if selected else curses.A_NORMAL
383
+ _addline(stdscr, yy, 0, marker + option_labels[i], attr)
384
+ yy += 1
385
+
386
+ fy = h - footer_h
387
+ for line in footer_lines:
388
+ _addline(stdscr, fy, 0, line)
389
+ fy += 1
390
+
391
+ stdscr.refresh()
392
+
393
+
394
+ def _menu_select(stdscr, header_lines, options, index: int = 0):
395
+ """Arrow-key menu. options: list[(label, value)]. Returns value or _CANCEL."""
396
+ n = len(options)
397
+ if n == 0:
398
+ return _CANCEL
399
+ index = max(0, min(index, n - 1))
400
+ footer = ["", "↑/↓ move · Enter select · Esc/← back"]
401
+ while True:
402
+ _menu_draw(stdscr, header_lines, [o[0] for o in options], index, footer)
403
+ key = stdscr.getch()
404
+ if key in (curses.KEY_UP, ord("k")):
405
+ index = (index - 1) % n
406
+ elif key in (curses.KEY_DOWN, ord("j")):
407
+ index = (index + 1) % n
408
+ elif key == curses.KEY_HOME:
409
+ index = 0
410
+ elif key == curses.KEY_END:
411
+ index = n - 1
412
+ elif key in (curses.KEY_ENTER, 10, 13):
413
+ return options[index][1]
414
+ elif key in (27, ord("q"), curses.KEY_LEFT):
415
+ return _CANCEL
416
+
417
+
418
+ def _text_input(stdscr, prompt: str) -> str | None:
419
+ h, w = stdscr.getmaxyx()
420
+ y = h - 1
421
+ _addline(stdscr, y, 0, " " * (w - 1))
422
+ _addline(stdscr, y, 0, prompt, curses.A_BOLD)
423
+ stdscr.refresh()
424
+ curses.echo()
425
+ try:
426
+ curses.curs_set(1)
427
+ except Exception:
428
+ pass
429
+ try:
430
+ raw = stdscr.getstr(y, min(len(prompt), w - 2), 200)
431
+ text = raw.decode("utf-8", "replace").strip()
432
+ except Exception:
433
+ text = ""
434
+ finally:
435
+ curses.noecho()
436
+ try:
437
+ curses.curs_set(0)
438
+ except Exception:
439
+ pass
440
+ return text or None
441
+
442
+
443
+ def _confirm(stdscr, question: str):
444
+ """Yes/No prompt. Returns True, False, or None (cancel)."""
445
+ sel = _menu_select(
446
+ stdscr,
447
+ [question, ""],
448
+ [("Yes, save changes", True), ("No, discard changes", False)],
449
+ index=0,
450
+ )
451
+ return None if sel is _CANCEL else sel
452
+
453
+
454
+ # ── tier edit flow ─────────────────────────────────────────────────
455
+
456
+ def _edit_tier(stdscr, config: GatewayConfig, provider_names: list[str], tier: str) -> bool:
457
+ """Pick a provider then a model for `tier`. Returns True if a change was applied."""
458
+ cur_prov, cur_model = _tier_current(config, tier)
459
+
460
+ prov_options = []
461
+ for name in provider_names:
462
+ prov = config.providers[name]
463
+ models = ", ".join(prov.available_models) if prov.available_models else "(no models listed)"
464
+ prov_options.append((f"{name:16s} {models}", name))
465
+
466
+ start = provider_names.index(cur_prov) if cur_prov in provider_names else 0
467
+ header = [
468
+ f"Tier '{tier}' — current: {_format_target(cur_prov, cur_model)}",
469
+ "Choose a provider:",
470
+ "",
471
+ ]
472
+ provider_name = _menu_select(stdscr, header, prov_options, index=start)
473
+ if provider_name is _CANCEL:
474
+ return False
475
+
476
+ prov = config.providers[provider_name]
477
+ model_options: list[tuple[str, object]] = [
478
+ (m + (" (provider default)" if i == 0 else ""), m)
479
+ for i, m in enumerate(prov.available_models)
480
+ ]
481
+ if prov.available_models:
482
+ model_options.append(("Use provider default (first available model)", None))
483
+ model_options.append(("Custom model…", _CUSTOM))
484
+
485
+ midx = 0
486
+ if provider_name == cur_prov:
487
+ if cur_model is None and prov.available_models:
488
+ midx = len(prov.available_models) # the explicit "provider default" row
489
+ else:
490
+ for i, (_, val) in enumerate(model_options):
491
+ if val == cur_model:
492
+ midx = i
493
+ break
494
+
495
+ header = [
496
+ f"Tier '{tier}' — provider: {provider_name}",
497
+ "Choose a model:",
498
+ "",
499
+ ]
500
+ selection = _menu_select(stdscr, header, model_options, index=midx)
501
+ if selection is _CANCEL:
502
+ return False
503
+
504
+ if selection is _CUSTOM:
505
+ text = _text_input(stdscr, f"Custom model name for {provider_name}: ")
506
+ if not text:
507
+ return False
508
+ model: str | None = text
509
+ else:
510
+ model = selection # type: ignore[assignment] # str or None
511
+
512
+ # Guard against creating an invalid route (no model + no fallback).
513
+ if model is None and not prov.available_models:
514
+ return False
515
+
516
+ _set_tier_route(config, tier, provider_name, model)
517
+ return True
518
+
519
+
520
+ def _edit_loop(stdscr, config: GatewayConfig, config_path: str):
521
+ """Main TUI loop. Returns (outcome, reload_msg, saved_any)."""
522
+ try:
523
+ curses.curs_set(0)
524
+ except Exception:
525
+ pass
526
+ stdscr.keypad(True)
527
+
528
+ tiers = list(TIER_PATTERNS)
529
+ provider_names = list(config.providers)
530
+ index = 0
531
+ dirty = False
532
+ saved_any = False
533
+ reload_msg: str | None = None
534
+ status = ""
535
+
536
+ while True:
537
+ header = [
538
+ f"Flexgate config — {os.path.abspath(config_path)}",
539
+ "↑/↓ move · Enter edit tier · s save · q quit",
540
+ "",
541
+ ]
542
+ labels = []
543
+ for tier in tiers:
544
+ prov, model = _tier_current(config, tier)
545
+ labels.append(f"{tier:8s} {_format_target(prov, model)}")
546
+ footer = [
547
+ "",
548
+ "● unsaved changes" if dirty else "○ no unsaved changes",
549
+ status,
550
+ ]
551
+ _menu_draw(stdscr, header, labels, index, footer)
552
+
553
+ key = stdscr.getch()
554
+ if key in (curses.KEY_UP, ord("k")):
555
+ index = (index - 1) % len(tiers)
556
+ status = ""
557
+ elif key in (curses.KEY_DOWN, ord("j")):
558
+ index = (index + 1) % len(tiers)
559
+ status = ""
560
+ elif key in (curses.KEY_ENTER, 10, 13):
561
+ if _edit_tier(stdscr, config, provider_names, tiers[index]):
562
+ dirty = True
563
+ prov, model = _tier_current(config, tiers[index])
564
+ status = f"Set {tiers[index]} → {_format_target(prov, model)}"
565
+ else:
566
+ status = ""
567
+ elif key == ord("s"):
568
+ if dirty:
569
+ save_config(config, config_path)
570
+ reload_msg = _signal_reload(config_path)
571
+ saved_any = True
572
+ dirty = False
573
+ status = "Saved" + (f" · {reload_msg}" if reload_msg else "")
574
+ else:
575
+ status = "No changes to save"
576
+ elif key in (ord("q"), 27):
577
+ if dirty:
578
+ ans = _confirm(stdscr, "Unsaved changes — save before quitting?")
579
+ if ans is True:
580
+ save_config(config, config_path)
581
+ reload_msg = _signal_reload(config_path)
582
+ return ("saved", reload_msg, True)
583
+ if ans is False:
584
+ return ("discarded", reload_msg, saved_any)
585
+ status = "" # cancelled the quit
586
+ continue
587
+ return ("saved" if saved_any else "clean", reload_msg, saved_any)
588
+
589
+
590
+ def cmd_config_edit(args: argparse.Namespace) -> None:
591
+ config_path = args.config
592
+ if not os.path.exists(config_path):
593
+ print(f"Config not found: {config_path}")
594
+ print("Run 'flexgate config init' first.")
595
+ sys.exit(1)
596
+
597
+ config = load_config(config_path)
598
+ if not config.providers:
599
+ print("No providers configured.")
600
+ print(f"Add providers to {config_path} or run 'flexgate settings import' first.")
601
+ sys.exit(1)
602
+
603
+ if curses is None:
604
+ print("Interactive editor requires the 'curses' module (unavailable on this platform).")
605
+ print("Use 'flexgate config set <tier> <provider> [model]' instead.")
606
+ sys.exit(1)
607
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
608
+ print("Interactive editor requires an interactive terminal (TTY).")
609
+ print("Use 'flexgate config set <tier> <provider> [model]' for non-interactive changes.")
610
+ sys.exit(1)
611
+
612
+ outcome, reload_msg, saved_any = curses.wrapper(_edit_loop, config, config_path)
613
+
614
+ if outcome == "saved":
615
+ print(f"Saved: {config_path}")
616
+ if reload_msg:
617
+ print(reload_msg)
618
+ elif outcome == "discarded":
619
+ if saved_any:
620
+ print(f"Saved earlier changes to {config_path}; discarded changes since last save.")
621
+ if reload_msg:
622
+ print(reload_msg)
623
+ else:
624
+ print("Discarded changes.")
625
+ else:
626
+ print("No changes.")
627
+
628
+
629
+ # ── service subcommands ────────────────────────────────────────────
630
+
631
+ def cmd_service_install(args: argparse.Namespace) -> None:
632
+ from flexgate.service import service_install
633
+ service_install(
634
+ _service_config_arg(args),
635
+ start=not getattr(args, "no_start", False),
636
+ no_claude_settings=getattr(args, "no_claude_settings", False),
637
+ )
638
+
639
+
640
+ def cmd_service_uninstall(args: argparse.Namespace) -> None:
641
+ from flexgate.service import service_uninstall
642
+ service_uninstall()
643
+
644
+
645
+ def cmd_service_start(args: argparse.Namespace) -> None:
646
+ from flexgate.service import service_start
647
+ service_start(_service_config_arg(args))
648
+
649
+
650
+ def cmd_service_stop(args: argparse.Namespace) -> None:
651
+ from flexgate.service import service_stop
652
+ service_stop()
653
+
654
+
655
+ def cmd_service_restart(args: argparse.Namespace) -> None:
656
+ from flexgate.service import service_restart
657
+ service_restart(_service_config_arg(args))
658
+
659
+
660
+ def cmd_service_reload(args: argparse.Namespace) -> None:
661
+ from flexgate.service import service_reload
662
+ service_reload()
663
+
664
+
665
+ def cmd_service_status(args: argparse.Namespace) -> None:
666
+ from flexgate.service import service_status
667
+ print(f"flexgate {__version__}")
668
+ config_path = service_status() or args.config
669
+ try:
670
+ config = load_config(config_path)
671
+ except Exception as e:
672
+ print(f"\nRoutes unavailable: could not load {config_path}: {e}")
673
+ return
674
+ _print_active_routes(config)
675
+ _print_update_notice()
676
+
677
+
678
+ def _print_update_notice() -> None:
679
+ """Show a one-line hint when PyPI carries a newer flexgate (cached, silent)."""
680
+ from flexgate.update import update_notice
681
+ notice = update_notice()
682
+ if notice:
683
+ print(f"\n{notice}")
684
+
685
+
686
+ def cmd_service_help(args: argparse.Namespace) -> None:
687
+ from flexgate.service import service_help
688
+ service_help()
689
+
690
+
691
+ def _print_sync_help() -> None:
692
+ print("""\
693
+ Infisical sync (flexgate sync):
694
+ Requires the infisical CLI to be installed and logged in ('infisical login');
695
+ sync checks both before doing anything and prints setup hints if missing.
696
+ Connection is configured via the 'infisical:' section in config.yaml
697
+ (project_id, env). Each provider is a folder under /providers named after
698
+ the provider, holding an API_KEY secret (optional MODELS secret, comma
699
+ separated). 'flexgate sync' downloads the latest keys into config.yaml and
700
+ hot-reloads the running service; folders matching a known prefix
701
+ (flexgate/registry.py) are auto-imported as new providers, unknown prefixes
702
+ produce a warning. Use --dry-run to preview without writing.
703
+ """)
704
+
705
+
706
+ # ── doctor / update ───────────────────────────────────────────────
707
+
708
+ def cmd_doctor(args: argparse.Namespace) -> None:
709
+ from flexgate.doctor import run_doctor
710
+ sys.exit(run_doctor(args.config, offline=getattr(args, "offline", False)))
711
+
712
+
713
+ def cmd_update(args: argparse.Namespace) -> None:
714
+ from flexgate.update import run_update
715
+ sys.exit(run_update(
716
+ args.config,
717
+ check=getattr(args, "check", False),
718
+ config_only=getattr(args, "config_only", False),
719
+ ))
720
+
721
+
722
+ # ── default action (no subcommand) ────────────────────────────────
723
+
724
+ def cmd_default(args: argparse.Namespace) -> None:
725
+ """Smart default when `flexgate` is invoked with no subcommand.
726
+
727
+ - service running → show status (same as `flexgate service status`)
728
+ - service installed → show status + hint how to start it
729
+ - otherwise (fresh boot) → show how to install the service
730
+ """
731
+ from flexgate.service import service_active, service_installed, service_status
732
+
733
+ config_path = args.config
734
+
735
+ print(f"flexgate {__version__}")
736
+ _print_update_notice()
737
+ if service_active() or service_installed():
738
+ service_status()
739
+ try:
740
+ config = load_config(config_path)
741
+ except Exception as e:
742
+ print(f"\nRoutes unavailable: could not load {config_path}: {e}")
743
+ return
744
+ _print_active_routes(config)
745
+ if not service_active():
746
+ print("\nThe service is installed but not running.")
747
+ print("Start it with: flexgate service start")
748
+ else:
749
+ print("\nThe service is running.")
750
+ print("Stop/restart with: flexgate service stop|restart")
751
+ return
752
+
753
+ # Not installed: show how to set up and install the service.
754
+ print("The flexgate service is not installed yet.\n")
755
+ if not os.path.exists(config_path):
756
+ print("1. Create a config and add your API keys:")
757
+ print(" flexgate config init # writes a template to ~/.flexgate/config.yaml")
758
+ print(" flexgate config edit # or pick providers/models interactively")
759
+ print("2. Install and start the persistent service:")
760
+ print(" flexgate service install")
761
+ else:
762
+ print(f"Found an existing config at: {config_path}")
763
+ print("Install and start the persistent service with:")
764
+ print(" flexgate service install")
765
+
766
+
767
+ # ── main ───────────────────────────────────────────────────────────
768
+
769
+ def main() -> None:
770
+ parser = argparse.ArgumentParser(
771
+ prog="flexgate",
772
+ description=(
773
+ "Flexgate — Flexible API Gateway for Claude Code. "
774
+ "Persistent serving uses the systemd user service."
775
+ ),
776
+ )
777
+ parser.add_argument("--config", default=None, help="Config file (default: ~/.flexgate/config.yaml)")
778
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
779
+
780
+ sub = parser.add_subparsers(dest="group")
781
+
782
+ # flexgate service ... (primary persistent runtime)
783
+ svc = sub.add_parser("service", help="Manage the primary systemd user service")
784
+ svc_sub = svc.add_subparsers(dest="command")
785
+ svc_install = svc_sub.add_parser("install", help="Install + enable the systemd user service")
786
+ svc_install.add_argument(
787
+ "--no-start", action="store_true",
788
+ help="Install and enable the service but do not start it now"
789
+ )
790
+ svc_install.add_argument(
791
+ "--no-claude-settings", action="store_true",
792
+ help="Skip the interactive prompt to overwrite ~/.claude/settings.json"
793
+ )
794
+ svc_sub.add_parser("uninstall", help="Stop, disable and remove the systemd user service")
795
+ svc_sub.add_parser("start", help="Start the service")
796
+ svc_sub.add_parser("stop", help="Stop the service")
797
+ svc_sub.add_parser("restart", help="Restart the service")
798
+ svc_sub.add_parser("reload", help="Reload config without restarting")
799
+ svc_sub.add_parser("status", help="Show service status and active routes")
800
+ svc_sub.add_parser("help", help="Show service command help")
801
+
802
+ # flexgate run / check (foreground debugging) ...
803
+ run_p = sub.add_parser("run", help="Run one foreground server for debugging")
804
+ run_p.add_argument(
805
+ "--port",
806
+ type=int,
807
+ default=None,
808
+ help="Override listen port for this foreground run only",
809
+ )
810
+ check_p = sub.add_parser("check", help="Verify upstream provider connectivity")
811
+ check_p.add_argument(
812
+ "--verify-timeout", type=float, default=15.0,
813
+ help="Per-provider connectivity check timeout in seconds (default: 15.0)"
814
+ )
815
+
816
+ # flexgate settings ...
817
+ st = sub.add_parser("settings", help="Manage Claude Code settings")
818
+ st_sub = st.add_subparsers(dest="command")
819
+ st_sub.add_parser("import", help="Import ~/.claude/settings.json* into config.yaml")
820
+ st_sub.add_parser("apply", help="Apply config.yaml to ~/.claude/settings.json")
821
+
822
+ # flexgate sync ...
823
+ sy = sub.add_parser("sync", help="Pull provider API keys from Infisical into config.yaml")
824
+ sy.add_argument(
825
+ "--dry-run", action="store_true",
826
+ help="Show which keys would change without writing the config"
827
+ )
828
+
829
+ # flexgate help ...
830
+ sub.add_parser("help", help="Show help, including Infisical sync details")
831
+
832
+ # flexgate doctor / update ...
833
+ doc_p = sub.add_parser("doctor", help="Diagnose installation and config problems")
834
+ doc_p.add_argument(
835
+ "--offline", action="store_true",
836
+ help="Skip the PyPI check for a newer flexgate release"
837
+ )
838
+ up_p = sub.add_parser("update", help="Upgrade flexgate (pip) and migrate the config schema")
839
+ up_p.add_argument(
840
+ "--check", action="store_true",
841
+ help="Only report what would change, without modifying anything"
842
+ )
843
+ up_p.add_argument(
844
+ "--config-only", action="store_true",
845
+ help="Only migrate the config schema; skip the package upgrade"
846
+ )
847
+
848
+ # flexgate config ...
849
+ cf = sub.add_parser("config", help="View and manage configuration")
850
+ cf_sub = cf.add_subparsers(dest="command")
851
+ cf_sub.add_parser("init", help="Create default config at ~/.flexgate/config.yaml")
852
+ cf_sub.add_parser("show", help="Show current configuration")
853
+ cf_sub.add_parser("path", help="Print config file path")
854
+ cf_sub.add_parser("edit", help="Interactively choose provider/model per tier (opus/sonnet/haiku)")
855
+ cf_set = cf_sub.add_parser("set", help="Set route for a tier")
856
+ cf_set.add_argument("tier", help="Tier: all, or comma-separated opus,sonnet,haiku")
857
+ cf_set.add_argument("target", help="Provider name or model name")
858
+ cf_set.add_argument("model", nargs="?", default=None, help="Model override (when target is a provider)")
859
+
860
+ args = parser.parse_args()
861
+
862
+ # Resolve config path and ensure home directory
863
+ args.config_explicit = args.config is not None or bool(os.environ.get("FLEXGATE_CONFIG"))
864
+ if args.config is None:
865
+ args.config = get_default_config_path()
866
+ ensure_home_dir()
867
+
868
+ if args.group == "run":
869
+ cmd_run(args)
870
+
871
+ elif args.group == "check":
872
+ cmd_check(args)
873
+
874
+ elif args.group == "settings":
875
+ handlers = {
876
+ "import": cmd_settings_import,
877
+ "apply": cmd_settings_apply,
878
+ }
879
+ handler = handlers.get(args.command)
880
+ if not handler:
881
+ st.print_help()
882
+ sys.exit(1)
883
+ handler(args)
884
+
885
+ elif args.group == "config":
886
+ handlers = {
887
+ "init": cmd_config_init,
888
+ "show": cmd_config_show,
889
+ "set": cmd_config_set,
890
+ "edit": cmd_config_edit,
891
+ "path": cmd_config_path,
892
+ }
893
+ handler = handlers.get(args.command)
894
+ if not handler:
895
+ # `flexgate config` with no subcommand → interactive editor
896
+ handler = cmd_config_edit
897
+ handler(args)
898
+
899
+ elif args.group == "service":
900
+ handlers = {
901
+ "install": cmd_service_install,
902
+ "uninstall": cmd_service_uninstall,
903
+ "start": cmd_service_start,
904
+ "stop": cmd_service_stop,
905
+ "restart": cmd_service_restart,
906
+ "reload": cmd_service_reload,
907
+ "status": cmd_service_status,
908
+ "help": cmd_service_help,
909
+ }
910
+ handler = handlers.get(args.command)
911
+ if not handler:
912
+ svc.print_help()
913
+ sys.exit(1)
914
+ handler(args)
915
+
916
+ elif args.group == "sync":
917
+ cmd_sync(args)
918
+
919
+ elif args.group == "doctor":
920
+ cmd_doctor(args)
921
+
922
+ elif args.group == "update":
923
+ cmd_update(args)
924
+
925
+ elif args.group == "help":
926
+ parser.print_help()
927
+ print()
928
+ _print_sync_help()
929
+
930
+ else:
931
+ cmd_default(args)
932
+
933
+
934
+ if __name__ == "__main__":
935
+ main()