sing-box-cli 0.0.26__tar.gz → 0.0.27__tar.gz

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.
Files changed (34) hide show
  1. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/CHANGELOG.md +19 -0
  2. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/PKG-INFO +1 -1
  3. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/pyproject.toml +1 -1
  4. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/src/sing_box_service/__init__.py +1 -1
  5. sing_box_cli-0.0.27/src/sing_box_service/api/__init__.py +99 -0
  6. {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/client.py +47 -27
  7. sing_box_cli-0.0.27/src/sing_box_service/api/logs.py +93 -0
  8. {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/monitor.py +1 -1
  9. {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/policy.py +14 -51
  10. sing_box_cli-0.0.27/src/sing_box_service/common.py +26 -0
  11. sing_box_cli-0.0.27/src/sing_box_service/config/__init__.py +46 -0
  12. {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/config}/config.py +24 -57
  13. sing_box_cli-0.0.27/src/sing_box_service/config/utils.py +51 -0
  14. sing_box_cli-0.0.27/src/sing_box_service/main.py +74 -0
  15. sing_box_cli-0.0.27/src/sing_box_service/service/__init__.py +71 -0
  16. sing_box_cli-0.0.26/src/sing_box_service/service.py → sing_box_cli-0.0.27/src/sing_box_service/service/manager.py +21 -29
  17. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/tests/test_config.py +1 -1
  18. sing_box_cli-0.0.26/src/sing_box_service/main.py +0 -262
  19. sing_box_cli-0.0.26/src/sing_box_service/run.py +0 -24
  20. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/.github/dependabot.yml +0 -0
  21. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/.github/workflows/main.yml +0 -0
  22. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/.gitignore +0 -0
  23. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/.pre-commit-config.yaml +0 -0
  24. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/LICENSE +0 -0
  25. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/README.md +0 -0
  26. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/assets/image.png +0 -0
  27. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/scripts/bump.sh +0 -0
  28. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/scripts/format.sh +0 -0
  29. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/scripts/lint.sh +0 -0
  30. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/scripts/pre-commit.sh +0 -0
  31. {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/connections.py +0 -0
  32. {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.27/src/sing_box_service/api}/widget.py +0 -0
  33. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/src/sing_box_service/py.typed +0 -0
  34. {sing_box_cli-0.0.26 → sing_box_cli-0.0.27}/uv.lock +0 -0
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.0.27 - 2025-03-21
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - Catch `asyncio.exceptions.CancelledError` while canceling stats display
10
+ - Logic of load groups, when to update delay and load delay history
11
+
12
+ ### 🚜 Refactor
13
+
14
+ - Replace SingBoxCli class with typer context and modularize commands files
15
+ - Make_stream_request
16
+ - Display logs from api_client
17
+ - Simplify config_dir
18
+
19
+ ### ⚙️ Miscellaneous Tasks
20
+
21
+ - Remove EmptyResponse
22
+ - Simplify api commands in top level
23
+
5
24
  ## 0.0.26 - 2025-03-20
6
25
 
7
26
  ### 🚜 Refactor
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sing-box-cli
3
- Version: 0.0.26
3
+ Version: 0.0.27
4
4
  Summary: cross-platform sing-box-service manager.
5
5
  Project-URL: Homepage, https://github.com/aittcuszz/sing-box-service
6
6
  Project-URL: Issues, https://github.com/atticuszz/sing-box-service/issues
@@ -213,7 +213,7 @@ sort_commits = "oldest"
213
213
 
214
214
 
215
215
  [tool.bumpversion]
216
- current_version = "0.0.26"
216
+ current_version = "0.0.27"
217
217
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
218
218
  serialize = ["{major}.{minor}.{patch}"]
219
219
  search = "{current_version}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.0.26"
5
+ __version__ = "0.0.27"
@@ -0,0 +1,99 @@
1
+ import asyncio
2
+ from functools import lru_cache
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from ..common import LogLevel, StrOrNone
8
+ from ..config import SingBoxConfig
9
+ from .client import SingBoxAPIClient
10
+ from .connections import ConnectionsManager
11
+ from .logs import get_logs
12
+ from .monitor import ResourceMonitor, ResourceVisualizer
13
+ from .policy import PolicyGroupManager
14
+
15
+ ApiUrlOption = Annotated[
16
+ StrOrNone,
17
+ typer.Option(
18
+ "--base-url",
19
+ "-u",
20
+ help="Base URL of the sing-box API, read from configuration file if not provided",
21
+ ),
22
+ ]
23
+ ApiTokenOption = Annotated[
24
+ StrOrNone,
25
+ typer.Option(
26
+ "--token",
27
+ "-t",
28
+ help="Authentication token for the sing-box API, read from configuration file if not provided",
29
+ ),
30
+ ]
31
+
32
+ LogLevelOption = Annotated[
33
+ LogLevel,
34
+ typer.Option(
35
+ "--log-level",
36
+ "-l",
37
+ help="Log level of trace, debug, info, warning, error, fatal, panic",
38
+ case_sensitive=False,
39
+ ),
40
+ ]
41
+
42
+ api = typer.Typer(help="sing-box manager.")
43
+
44
+
45
+ @lru_cache
46
+ def create_client(
47
+ config: SingBoxConfig, base_url: StrOrNone = None, token: StrOrNone = None
48
+ ) -> SingBoxAPIClient:
49
+ # read from config if not provided
50
+ if base_url is None:
51
+ base_url = config.api_base_url
52
+ if token is None:
53
+ token = config.api_secret
54
+ return SingBoxAPIClient(base_url, token)
55
+
56
+
57
+ @api.command()
58
+ def stats(
59
+ ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
60
+ ) -> None:
61
+ """Show sing-box traffic, memory statistics and connections, requires API token(Optional)"""
62
+
63
+ api_client = create_client(ctx.obj.config, base_url, token)
64
+ visualizer = ResourceVisualizer()
65
+ monitor = ResourceMonitor(api_client, visualizer)
66
+ asyncio.run(monitor.start())
67
+
68
+
69
+ @api.command()
70
+ def conns(
71
+ ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
72
+ ) -> None:
73
+ """Manage sing-box connections, requires API token(Optional)"""
74
+ api_client = create_client(ctx.obj.config, base_url, token)
75
+ manager = ConnectionsManager(api_client)
76
+ asyncio.run(manager.run())
77
+
78
+
79
+ @api.command()
80
+ def proxy(
81
+ ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
82
+ ) -> None:
83
+ """Manage sing-box policy groups, requires API token(Optional)"""
84
+ api_client = create_client(ctx.obj.config, base_url, token)
85
+ manager = PolicyGroupManager(api_client)
86
+ asyncio.run(manager.run())
87
+
88
+
89
+ @api.command()
90
+ def logs(
91
+ ctx: typer.Context,
92
+ log_level: LogLevelOption = LogLevel.info,
93
+ base_url: ApiUrlOption = None,
94
+ token: ApiTokenOption = None,
95
+ ) -> None:
96
+ """Show sing-box logs, requires API token(Optional)"""
97
+ api_client = create_client(ctx.obj.config, base_url, token)
98
+ print("⌛ Showing real-time logs (Press Ctrl+C to exit)")
99
+ asyncio.run(get_logs(api_client, log_level))
@@ -75,7 +75,7 @@ class GroupInfo(FrozenBaseModel):
75
75
  name: str
76
76
  udp: bool = Field(description="UDP support")
77
77
  history: list[DelayHistory] = Field(
78
- description="History of selected proxies", default=[]
78
+ description="History delay of selected proxies", default=[]
79
79
  )
80
80
  now: str = Field(description="Currently selected proxy")
81
81
  all: list[str] = Field(description="List of all outbound proxies")
@@ -90,13 +90,13 @@ class GroupsData(FrozenBaseModel):
90
90
  class DelayTestResult(FrozenBaseModel):
91
91
  """Delay test result model."""
92
92
 
93
+ outbound: str = Field(default="", description="Outbound proxy name")
93
94
  delay: int = Field(default=0, description="Outbound delay in milliseconds")
94
95
 
95
96
 
96
- class EmptyResponse(FrozenBaseModel):
97
- """Empty response model."""
98
-
99
- status_code: int
97
+ class LogEntry(FrozenBaseModel):
98
+ type: str = Field(description="Logs level")
99
+ payload: str = Field(description="Log message")
100
100
 
101
101
 
102
102
  # Generic response type for API methods
@@ -187,29 +187,46 @@ class SingBoxAPIClient:
187
187
  raw = await self._make_request_raw(method, endpoint, params, data)
188
188
  return model_class.model_validate(raw)
189
189
 
190
- async def traffic_stream(self) -> AsyncGenerator[TrafficData, None]:
190
+ async def _make_stream_request(
191
+ self, endpoint: str, model_class: type[T], params: dict[str, str] | None = None
192
+ ) -> AsyncGenerator[T, None]:
191
193
  """
192
- Get traffic statistics as a stream of updates.
194
+ Make a streaming request to the API.
195
+
196
+ Args:
197
+ endpoint: API endpoint
198
+ model_class: Pydantic model class to validate response data
199
+ params: Optional query parameters for the request
193
200
 
194
201
  Yields:
195
- TrafficData object containing traffic data (up/down in B/s)
202
+ Validated model instances from the stream
196
203
  """
197
- url = f"{self.base_url}/traffic"
204
+ url = f"{self.base_url}{endpoint}"
198
205
 
199
206
  async with httpx.AsyncClient() as client:
200
207
  async with client.stream(
201
- "GET", url, headers=self.headers, timeout=None
208
+ "GET", url, headers=self.headers, params=params, timeout=None
202
209
  ) as response:
203
210
  response.raise_for_status()
204
211
  async for line in response.aiter_lines():
205
212
  if line.strip(): # Skip empty lines
206
213
  try:
207
214
  data = json.loads(line)
208
- yield TrafficData.model_validate(data)
215
+ yield model_class.model_validate(data)
209
216
  except json.JSONDecodeError:
210
217
  # Skip invalid JSON lines
211
218
  continue
212
219
 
220
+ async def traffic_stream(self) -> AsyncGenerator[TrafficData, None]:
221
+ """
222
+ Get traffic statistics as a stream of updates.
223
+
224
+ Yields:
225
+ TrafficData object containing traffic data (up/down in B/s)
226
+ """
227
+ async for data in self._make_stream_request("/traffic", TrafficData):
228
+ yield data
229
+
213
230
  async def memory_stream(self) -> AsyncGenerator[MemoryData, None]:
214
231
  """
215
232
  Get memory statistics as a stream of updates.
@@ -217,21 +234,21 @@ class SingBoxAPIClient:
217
234
  Yields:
218
235
  MemoryData object containing memory data (inuse/total in bytes)
219
236
  """
220
- url = f"{self.base_url}/memory"
237
+ async for data in self._make_stream_request("/memory", MemoryData):
238
+ yield data
221
239
 
222
- async with httpx.AsyncClient() as client:
223
- async with client.stream(
224
- "GET", url, headers=self.headers, timeout=None
225
- ) as response:
226
- response.raise_for_status()
227
- async for line in response.aiter_lines():
228
- if line.strip(): # Skip empty lines
229
- try:
230
- data = json.loads(line)
231
- yield MemoryData.model_validate(data)
232
- except json.JSONDecodeError:
233
- # Skip invalid JSON lines
234
- continue
240
+ async def log_stream(
241
+ self, level: str | None = None
242
+ ) -> AsyncGenerator[LogEntry, None]:
243
+ """
244
+ Get log entries as a stream of updates.
245
+
246
+ Yields:
247
+ LogEntry object containing log data (type/payload)
248
+ """
249
+ params = {"level": level} if level else None
250
+ async for data in self._make_stream_request("/logs", LogEntry, params=params):
251
+ yield data
235
252
 
236
253
  async def get_connections(self) -> ConnectionData:
237
254
  """
@@ -293,7 +310,7 @@ class SingBoxAPIClient:
293
310
  group_name: str,
294
311
  url: str = "https://cp.cloudflare.com/generate_204",
295
312
  timeout: int = 5000,
296
- ) -> dict[str, Any]:
313
+ ) -> list[DelayTestResult]:
297
314
  """
298
315
  Test delay for all proxies in a policy group.
299
316
 
@@ -302,9 +319,12 @@ class SingBoxAPIClient:
302
319
  timeout: Timeout in milliseconds
303
320
  """
304
321
  params = {"url": url, "timeout": timeout}
305
- return await self._make_request_raw(
322
+ reps = await self._make_request_raw(
306
323
  "GET", f"/group/{group_name}/delay", params=params
307
324
  )
325
+ return [
326
+ DelayTestResult(outbound=key, delay=value) for key, value in reps.items()
327
+ ]
308
328
 
309
329
  async def test_proxy_delay(
310
330
  self,
@@ -0,0 +1,93 @@
1
+ import asyncio
2
+ import re
3
+ from datetime import datetime
4
+
5
+ from rich import print
6
+ from rich.text import Text
7
+
8
+ from ..common import LogLevel
9
+ from .client import SingBoxAPIClient
10
+
11
+
12
+ async def get_logs(client: SingBoxAPIClient, log_level: LogLevel) -> None:
13
+ # Define color mapping for log levels with brighter colors
14
+ # Ref: https://rich.readthedocs.io/en/stable/appendix/colors.html
15
+ log_level_colors = {
16
+ "trace": "bright_black",
17
+ "debug": "grey66",
18
+ "info": "bright_cyan",
19
+ "warning": "bright_yellow",
20
+ "error": "bright_red",
21
+ "fatal": "bright_magenta",
22
+ "panic": "bright_magenta",
23
+ }
24
+
25
+ # Bright colors for IDs
26
+ id_colors = [
27
+ "bright_red",
28
+ "bright_green",
29
+ "bright_yellow",
30
+ "bright_blue",
31
+ "bright_magenta",
32
+ "bright_cyan",
33
+ ]
34
+
35
+ # Dictionary to store ID to color mapping
36
+ id_color_map = {}
37
+
38
+ try:
39
+ async for entry in client.log_stream(log_level.value.lower()):
40
+ # Get current timestamp with automatic timezone offset
41
+ now = datetime.now().astimezone()
42
+ timestamp = now.strftime("%z %Y-%m-%d %H:%M:%S")
43
+
44
+ # Get level color
45
+ # Start building the output message
46
+ message = Text()
47
+ message.append(timestamp, style="bright_black")
48
+ message.append(" ")
49
+ message.append(
50
+ entry.type.upper(),
51
+ style=log_level_colors.get(entry.type.lower(), "bright_white"),
52
+ )
53
+ message.append(" ")
54
+
55
+ # Process the payload
56
+ payload = entry.payload
57
+
58
+ # Find all ID patterns in the payload
59
+ pattern = r"\[(\d+) (\d+ms)\]"
60
+ last_end = 0
61
+
62
+ for match in re.finditer(pattern, payload):
63
+ # Add text before the match
64
+ message.append(payload[last_end : match.start()])
65
+
66
+ # Extract ID and time part
67
+ log_id = match.group(1)
68
+ time_part = match.group(2)
69
+
70
+ # Assign a consistent color to this ID
71
+ if log_id not in id_color_map:
72
+ color_index = hash(log_id) % len(id_colors)
73
+ id_color_map[log_id] = id_colors[color_index]
74
+
75
+ id_color = id_color_map[log_id]
76
+
77
+ # Add the ID with coloring
78
+ message.append("[", style="default")
79
+ message.append(log_id, style=id_color)
80
+ message.append(f" {time_part}]", style="default")
81
+
82
+ last_end = match.end()
83
+
84
+ # Add any remaining text
85
+ if last_end < len(payload):
86
+ message.append(payload[last_end:])
87
+
88
+ # Print the complete message
89
+ print(message)
90
+ except (KeyboardInterrupt, asyncio.CancelledError):
91
+ pass
92
+ except Exception as e:
93
+ print(f"[red]Error:[/red] {e}")
@@ -691,7 +691,7 @@ class ResourceMonitor:
691
691
 
692
692
  # Wait for next refresh
693
693
  await asyncio.sleep(self.visualizer.refresh_rate - 0.1)
694
- except KeyboardInterrupt:
694
+ except (asyncio.exceptions.CancelledError, KeyboardInterrupt):
695
695
  self.running = False
696
696
  break
697
697
  except Exception as e:
@@ -28,9 +28,7 @@ def create_policy_group_table(
28
28
  current = group_data.now
29
29
  proxies = group_data.all
30
30
  # Get delay information, prioritizing the cache
31
- # TODO: no sure if this is the best way to handle this
32
31
  history = group_data.history
33
- history_dict = {item.time: item.delay for item in history} if history else {}
34
32
 
35
33
  # Create table for proxies
36
34
  table = Table(expand=True, show_header=True)
@@ -48,8 +46,9 @@ def create_policy_group_table(
48
46
  latency = f"{delay} ms"
49
47
  else:
50
48
  latency = "Timeout"
51
- elif proxy in history_dict:
52
- delay = history_dict[proxy]
49
+ elif i == group_data.all.index(current):
50
+ # [0]->latest delay for the current proxy
51
+ delay = history[0].delay if history else -1
53
52
  if delay > 0:
54
53
  latency = f"{delay} ms"
55
54
  else:
@@ -92,7 +91,6 @@ class PolicyGroupManager:
92
91
  self.selected_proxy_index = 0
93
92
  self.current_group: GroupInfo | None = None
94
93
  self.status_message = "Loading policy groups..."
95
- self.running = False
96
94
 
97
95
  # Add a delay cache to store test results
98
96
  # Format: {group_name: {proxy_name: delay_value}}
@@ -126,6 +124,9 @@ class PolicyGroupManager:
126
124
  """Switch between group list and proxy list."""
127
125
  if self.focus_on_groups:
128
126
  self.focus_on_groups = False
127
+ # initiate a test for the selected group
128
+ if self.current_group:
129
+ asyncio.create_task(self.test_group_delay(self.current_group.name))
129
130
  else:
130
131
  self.focus_on_groups = True
131
132
 
@@ -298,19 +299,7 @@ class PolicyGroupManager:
298
299
  self.status_message = f"Loading group: {group_name}..."
299
300
 
300
301
  data = await self.api_client.get_group(group_name)
301
-
302
- # Make sure we have the history data
303
- # Try to fetch delay data if not present
304
- try:
305
- await self.api_client.test_group_delay(group_name)
306
- # Fetch the group data again to get updated delay information
307
- data = await self.api_client.get_group(group_name)
308
- except Exception:
309
- # If testing fails, continue with existing data
310
- pass
311
-
312
302
  self.current_group = data
313
- self.selected_proxy_index = 0
314
303
 
315
304
  # Find the index of the currently selected proxy
316
305
  current = data.now
@@ -372,16 +361,9 @@ class PolicyGroupManager:
372
361
  if group_name not in self.delay_cache:
373
362
  self.delay_cache[group_name] = {}
374
363
 
375
- # Test each proxy individually to ensure we get all results
376
- # TODO: Consider batching these requests
377
- for proxy in proxies:
378
- try:
379
- result = await self.api_client.test_proxy_delay(proxy)
380
- delay = result.delay
381
- self.delay_cache[group_name][proxy] = delay
382
- except Exception:
383
- # If one proxy test fails, continue with others
384
- pass
364
+ results = await self.api_client.test_group_delay(group_name)
365
+ for result in results:
366
+ self.delay_cache[group_name][result.outbound] = result.delay
385
367
 
386
368
  self.status_message = f"Tested all proxies in group: {group_name}"
387
369
  except Exception as e:
@@ -389,30 +371,11 @@ class PolicyGroupManager:
389
371
 
390
372
  async def run(self) -> None:
391
373
  """Run the policy group manager."""
392
- self.running = True
393
-
394
- # Initial load of groups
395
- await self.load_groups()
396
- # Setup periodic refresh task
397
- refresh_task = asyncio.create_task(self.periodic_refresh())
398
374
 
399
375
  try:
376
+ # Initial load of groups
377
+ await self.load_groups()
400
378
  await self.app.run_async()
401
- finally:
402
- self.running = False
403
- # Clean up refresh task
404
- refresh_task.cancel()
405
- try:
406
- await refresh_task
407
- except asyncio.CancelledError:
408
- pass
409
-
410
- async def periodic_refresh(self, interval: float = 5.0) -> None:
411
- """Periodically refresh the policy group data."""
412
- while self.running:
413
- try:
414
- await asyncio.sleep(interval)
415
- if self.current_group:
416
- await self.load_selected_group()
417
- except asyncio.CancelledError:
418
- break
379
+ except Exception as e:
380
+ self.status_message = f"Error running application: {str(e)}"
381
+ await asyncio.sleep(5)
@@ -0,0 +1,26 @@
1
+ import os
2
+ from enum import Enum
3
+
4
+ StrOrNone = str | None
5
+
6
+
7
+ class LogLevel(str, Enum):
8
+ trace = "trace"
9
+ debug = "debug"
10
+ info = "info"
11
+ warning = "warning"
12
+ error = "error"
13
+ fatal = "fatal"
14
+ panic = "panic"
15
+
16
+
17
+ def ensure_root() -> None:
18
+ """https://gist.github.com/RDCH106/fdd419ef7dd803932b16056aab1d2300"""
19
+ try:
20
+ if os.geteuid() != 0: # type: ignore
21
+ raise SystemError("⚠️ This script must be run as root.")
22
+ except AttributeError:
23
+ import ctypes
24
+
25
+ if not ctypes.windll.shell32.IsUserAnAdmin(): # type: ignore
26
+ raise SystemError("⚠️ This script must be run as Administrator.")
@@ -0,0 +1,46 @@
1
+ from typing import Annotated
2
+
3
+ import typer
4
+
5
+ from ..common import ensure_root
6
+ from .config import SingBoxConfig, get_config
7
+
8
+ __all__ = ["config", "SingBoxConfig", "get_config"]
9
+
10
+ config = typer.Typer(help="Configuration management commands")
11
+
12
+
13
+ @config.command("add-sub")
14
+ def config_add_sub(ctx: typer.Context, url: Annotated[str, typer.Argument()]) -> None:
15
+ """Add subscription URL"""
16
+ ensure_root()
17
+ if ctx.obj.config.add_subscription(url):
18
+ # restart service if subscription is updated
19
+ if not ctx.obj.service.check_service():
20
+ ctx.obj.service.create_service()
21
+ print("⌛ Service created successfully.")
22
+ if ctx.obj.config.update_config():
23
+ ctx.obj.service.restart()
24
+ else:
25
+ raise typer.Exit(1)
26
+ else:
27
+ print("❌ Failed to add subscription.")
28
+ raise typer.Exit(1)
29
+
30
+
31
+ @config.command("show-sub")
32
+ def config_show_sub(ctx: typer.Context) -> None:
33
+ """Show subscription URL"""
34
+ ctx.obj.config.show_subscription()
35
+
36
+
37
+ @config.command("show")
38
+ def config_show(ctx: typer.Context) -> None:
39
+ """Show configuration file"""
40
+ ctx.obj.config.show_config()
41
+
42
+
43
+ @config.command("clean_cache")
44
+ def config_clean_cache(ctx: typer.Context) -> None:
45
+ """Clean cache database"""
46
+ ctx.obj.config.clean_cache()