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/__init__.py +3 -0
- flexgate/__main__.py +3 -0
- flexgate/cli.py +935 -0
- flexgate/config.py +312 -0
- flexgate/doctor.py +241 -0
- flexgate/guardian.py +252 -0
- flexgate/healthcheck.py +197 -0
- flexgate/main.py +56 -0
- flexgate/migrate.py +138 -0
- flexgate/proxy.py +165 -0
- flexgate/registry.py +22 -0
- flexgate/router.py +75 -0
- flexgate/server.py +85 -0
- flexgate/service.py +1205 -0
- flexgate/settings.py +193 -0
- flexgate/sync.py +203 -0
- flexgate/update.py +188 -0
- flexgate-0.1.0.dist-info/METADATA +366 -0
- flexgate-0.1.0.dist-info/RECORD +21 -0
- flexgate-0.1.0.dist-info/WHEEL +4 -0
- flexgate-0.1.0.dist-info/entry_points.txt +2 -0
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()
|