clashctl 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.
Files changed (48) hide show
  1. clashctl/__init__.py +1 -0
  2. clashctl/__main__.py +4 -0
  3. clashctl/api/__init__.py +20 -0
  4. clashctl/api/client.py +164 -0
  5. clashctl/api/errors.py +24 -0
  6. clashctl/api/streams.py +45 -0
  7. clashctl/cli.py +55 -0
  8. clashctl/config/__init__.py +23 -0
  9. clashctl/config/__main__.py +29 -0
  10. clashctl/config/schema.py +135 -0
  11. clashctl/config/store.py +56 -0
  12. clashctl/models/__init__.py +28 -0
  13. clashctl/models/base.py +24 -0
  14. clashctl/models/config.py +37 -0
  15. clashctl/models/connection.py +46 -0
  16. clashctl/models/enums.py +115 -0
  17. clashctl/models/log.py +20 -0
  18. clashctl/models/proxy.py +94 -0
  19. clashctl/models/rule.py +39 -0
  20. clashctl/models/traffic.py +6 -0
  21. clashctl/models/version.py +6 -0
  22. clashctl/state/__init__.py +26 -0
  23. clashctl/state/app_state.py +148 -0
  24. clashctl/state/sort.py +280 -0
  25. clashctl/state/speed.py +69 -0
  26. clashctl/tui/__init__.py +3 -0
  27. clashctl/tui/app.py +314 -0
  28. clashctl/tui/messages.py +101 -0
  29. clashctl/tui/poller.py +194 -0
  30. clashctl/tui/screens/__init__.py +4 -0
  31. clashctl/tui/screens/main.py +185 -0
  32. clashctl/tui/screens/server_select.py +317 -0
  33. clashctl/tui/theme.py +69 -0
  34. clashctl/tui/widgets/__init__.py +15 -0
  35. clashctl/tui/widgets/connection_list.py +101 -0
  36. clashctl/tui/widgets/log_view.py +41 -0
  37. clashctl/tui/widgets/movable_table.py +191 -0
  38. clashctl/tui/widgets/proxy_tree.py +262 -0
  39. clashctl/tui/widgets/rule_list.py +74 -0
  40. clashctl/tui/widgets/status_panel.py +170 -0
  41. clashctl/utils/__init__.py +0 -0
  42. clashctl/utils/bytesize.py +47 -0
  43. clashctl/utils/duration.py +27 -0
  44. clashctl-0.1.0.dist-info/METADATA +170 -0
  45. clashctl-0.1.0.dist-info/RECORD +48 -0
  46. clashctl-0.1.0.dist-info/WHEEL +4 -0
  47. clashctl-0.1.0.dist-info/entry_points.txt +2 -0
  48. clashctl-0.1.0.dist-info/licenses/LICENSE +21 -0
clashctl/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
clashctl/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from clashctl.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ from clashctl.api.client import Clash
2
+ from clashctl.api.errors import (
3
+ AuthError,
4
+ ClashError,
5
+ HTTPStatusError,
6
+ NotFoundError,
7
+ StreamError,
8
+ )
9
+ from clashctl.api.streams import stream_logs, stream_traffic
10
+
11
+ __all__ = [
12
+ "AuthError",
13
+ "Clash",
14
+ "ClashError",
15
+ "HTTPStatusError",
16
+ "NotFoundError",
17
+ "StreamError",
18
+ "stream_logs",
19
+ "stream_traffic",
20
+ ]
clashctl/api/client.py ADDED
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ from types import TracebackType
4
+ from typing import Any, Self
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+
9
+ from clashctl.api.errors import (
10
+ AuthError,
11
+ ClashError,
12
+ HTTPStatusError,
13
+ NotFoundError,
14
+ )
15
+ from clashctl.models import (
16
+ ClashConfig,
17
+ Connections,
18
+ Delay,
19
+ Proxies,
20
+ Proxy,
21
+ Rules,
22
+ Version,
23
+ )
24
+
25
+
26
+ class Clash:
27
+ """Async client for the Clash external-controller REST API.
28
+
29
+ Streaming endpoints (`/traffic`, `/logs`) live in `api.streams` so the
30
+ client itself can keep a single bounded HTTP timeout.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ base_url: str,
36
+ secret: str | None = None,
37
+ timeout_ms: int = 5000,
38
+ ) -> None:
39
+ if not base_url.endswith("/"):
40
+ base_url = base_url + "/"
41
+ headers: dict[str, str] = {}
42
+ if secret:
43
+ headers["Authorization"] = f"Bearer {secret}"
44
+ self._base_url = base_url
45
+ self._secret = secret
46
+ self._client = httpx.AsyncClient(
47
+ base_url=base_url,
48
+ headers=headers,
49
+ timeout=timeout_ms / 1000,
50
+ )
51
+ # Separate client without timeout for long-haul streams; lazily created.
52
+ self._stream_client: httpx.AsyncClient | None = None
53
+
54
+ @property
55
+ def base_url(self) -> str:
56
+ return self._base_url
57
+
58
+ @property
59
+ def secret(self) -> str | None:
60
+ return self._secret
61
+
62
+ def stream_client(self) -> httpx.AsyncClient:
63
+ """Long-lived client without read timeout, for /traffic and /logs."""
64
+ if self._stream_client is None:
65
+ headers: dict[str, str] = {}
66
+ if self._secret:
67
+ headers["Authorization"] = f"Bearer {self._secret}"
68
+ self._stream_client = httpx.AsyncClient(
69
+ base_url=self._base_url,
70
+ headers=headers,
71
+ timeout=httpx.Timeout(connect=5.0, read=None, write=5.0, pool=5.0),
72
+ )
73
+ return self._stream_client
74
+
75
+ async def __aenter__(self) -> Self:
76
+ return self
77
+
78
+ async def __aexit__(
79
+ self,
80
+ exc_type: type[BaseException] | None,
81
+ exc: BaseException | None,
82
+ tb: TracebackType | None,
83
+ ) -> None:
84
+ await self.aclose()
85
+
86
+ async def aclose(self) -> None:
87
+ await self._client.aclose()
88
+ if self._stream_client is not None:
89
+ await self._stream_client.aclose()
90
+
91
+ async def _request(self, method: str, path: str, **kw: Any) -> httpx.Response:
92
+ try:
93
+ resp = await self._client.request(method, path, **kw)
94
+ except httpx.HTTPError as e:
95
+ raise ClashError(f"transport error: {e}") from e
96
+ self._check_status(resp)
97
+ return resp
98
+
99
+ @staticmethod
100
+ def _check_status(resp: httpx.Response) -> None:
101
+ sc = resp.status_code
102
+ if sc < 400:
103
+ return
104
+ if sc in (401, 403):
105
+ raise AuthError(f"authentication failed (HTTP {sc})")
106
+ if sc == 404:
107
+ raise NotFoundError(f"not found: {resp.request.url}")
108
+ raise HTTPStatusError(sc, resp.text[:200])
109
+
110
+ # --- one-shot endpoints -------------------------------------------------
111
+
112
+ async def version(self) -> Version:
113
+ r = await self._request("GET", "version")
114
+ return Version.model_validate_json(r.content)
115
+
116
+ async def configs(self) -> ClashConfig:
117
+ r = await self._request("GET", "configs")
118
+ return ClashConfig.model_validate_json(r.content)
119
+
120
+ async def reload_configs(self, path: str, force: bool = False) -> None:
121
+ endpoint = "configs?force" if force else "configs"
122
+ await self._request("PUT", endpoint, json={"path": path})
123
+
124
+ async def proxies(self) -> Proxies:
125
+ r = await self._request("GET", "proxies")
126
+ # Clash returns `{"proxies": {...}}`; unwrap.
127
+ data = r.json()
128
+ inner = data.get("proxies", data)
129
+ return Proxies.model_validate(inner)
130
+
131
+ async def proxy(self, name: str) -> Proxy:
132
+ r = await self._request("GET", f"proxies/{quote(name, safe='')}")
133
+ return Proxy.model_validate_json(r.content)
134
+
135
+ async def rules(self) -> Rules:
136
+ r = await self._request("GET", "rules")
137
+ return Rules.model_validate_json(r.content)
138
+
139
+ async def connections(self) -> Connections:
140
+ r = await self._request("GET", "connections")
141
+ return Connections.model_validate_json(r.content)
142
+
143
+ async def close_connection(self, conn_id: str) -> None:
144
+ await self._request("DELETE", f"connections/{quote(conn_id, safe='')}")
145
+
146
+ async def close_all_connections(self) -> None:
147
+ await self._request("DELETE", "connections")
148
+
149
+ async def select(self, group: str, proxy: str) -> None:
150
+ """Switch a Selector group's active member."""
151
+ await self._request(
152
+ "PUT",
153
+ f"proxies/{quote(group, safe='')}",
154
+ json={"name": proxy},
155
+ )
156
+
157
+ async def delay(self, proxy: str, test_url: str, timeout_ms: int) -> Delay:
158
+ """Probe a single proxy's latency. Raises on connection failure."""
159
+ r = await self._request(
160
+ "GET",
161
+ f"proxies/{quote(proxy, safe='')}/delay",
162
+ params={"url": test_url, "timeout": timeout_ms},
163
+ )
164
+ return Delay.model_validate_json(r.content)
clashctl/api/errors.py ADDED
@@ -0,0 +1,24 @@
1
+ class ClashError(Exception):
2
+ """Base error for the Clash API client."""
3
+
4
+
5
+ class AuthError(ClashError):
6
+ """401/403 from the Clash controller (bad or missing secret)."""
7
+
8
+
9
+ class NotFoundError(ClashError):
10
+ """404 from the Clash controller."""
11
+
12
+
13
+ class HTTPStatusError(ClashError):
14
+ """Non-2xx response that doesn't fit a more specific subclass."""
15
+
16
+ def __init__(self, status_code: int, message: str = "") -> None:
17
+ super().__init__(
18
+ f"HTTP {status_code}: {message}" if message else f"HTTP {status_code}"
19
+ )
20
+ self.status_code = status_code
21
+
22
+
23
+ class StreamError(ClashError):
24
+ """Streaming endpoint disconnected or produced unparseable data."""
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import AsyncIterator
5
+
6
+ import httpx
7
+
8
+ from clashctl.api.client import Clash
9
+ from clashctl.api.errors import StreamError
10
+ from clashctl.models import LogLine, Traffic
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ async def stream_traffic(client: Clash) -> AsyncIterator[Traffic]:
16
+ """Yield Traffic samples from `/traffic` until the connection drops."""
17
+ async for line in _stream_lines(client, "traffic"):
18
+ try:
19
+ yield Traffic.model_validate_json(line)
20
+ except ValueError as e:
21
+ log.debug("skipping bad /traffic line %r: %s", line[:80], e)
22
+
23
+
24
+ async def stream_logs(client: Clash) -> AsyncIterator[LogLine]:
25
+ """Yield LogLine items from `/logs` until the connection drops."""
26
+ async for line in _stream_lines(client, "logs"):
27
+ try:
28
+ yield LogLine.model_validate_json(line)
29
+ except ValueError as e:
30
+ log.debug("skipping bad /logs line %r: %s", line[:80], e)
31
+
32
+
33
+ async def _stream_lines(client: Clash, path: str) -> AsyncIterator[str]:
34
+ """Open a streaming GET and yield each non-empty line."""
35
+ sc = client.stream_client()
36
+ try:
37
+ async with sc.stream("GET", path) as resp:
38
+ if resp.status_code >= 400:
39
+ raise StreamError(f"{path} returned HTTP {resp.status_code}")
40
+ async for raw in resp.aiter_lines():
41
+ line = raw.strip()
42
+ if line:
43
+ yield line
44
+ except httpx.HTTPError as e:
45
+ raise StreamError(f"{path} disconnected: {e}") from e
clashctl/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ """`clashctl` entrypoint.
2
+
3
+ The full CLI surface is intentionally minimal — clashctl is a TUI app.
4
+ Server management lives inside the TUI (Phase 7).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import logging
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from clashctl import __version__
16
+ from clashctl.config import default_config_path, load_config
17
+ from clashctl.tui import ClashCtlApp
18
+
19
+
20
+ def _build_parser() -> argparse.ArgumentParser:
21
+ p = argparse.ArgumentParser(
22
+ prog="clashctl", description="TUI for the Clash proxy API"
23
+ )
24
+ p.add_argument(
25
+ "-c",
26
+ "--config",
27
+ type=Path,
28
+ default=None,
29
+ help=f"path to config TOML (default: {default_config_path()})",
30
+ )
31
+ p.add_argument("--debug", action="store_true", help="enable verbose logging")
32
+ p.add_argument("--version", action="version", version=f"clashctl {__version__}")
33
+ return p
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ args = _build_parser().parse_args(argv)
38
+
39
+ if args.debug:
40
+ # Textual swallows stdout/stderr in alt-screen; route to a file.
41
+ log_path = os.environ.get("CLASHCTL_DEBUG_LOG", "/tmp/clashctl.log")
42
+ logging.basicConfig(
43
+ filename=log_path,
44
+ level=logging.DEBUG,
45
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
46
+ )
47
+
48
+ cfg = load_config(args.config)
49
+ app = ClashCtlApp(cfg)
50
+ app.run()
51
+ return 0
52
+
53
+
54
+ if __name__ == "__main__":
55
+ sys.exit(main())
@@ -0,0 +1,23 @@
1
+ from clashctl.config.schema import (
2
+ AppConfig,
3
+ ConnSortConfig,
4
+ ProxySortConfig,
5
+ RuleSortConfig,
6
+ Server,
7
+ SortsConfig,
8
+ UiConfig,
9
+ )
10
+ from clashctl.config.store import default_config_path, load_config, save_config
11
+
12
+ __all__ = [
13
+ "AppConfig",
14
+ "ConnSortConfig",
15
+ "ProxySortConfig",
16
+ "RuleSortConfig",
17
+ "Server",
18
+ "SortsConfig",
19
+ "UiConfig",
20
+ "default_config_path",
21
+ "load_config",
22
+ "save_config",
23
+ ]
@@ -0,0 +1,29 @@
1
+ """`python -m clashctl.config show` — print the resolved config.
2
+
3
+ Useful for debugging path resolution and TOML parse issues during dev.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+
11
+ from clashctl.config import default_config_path, load_config
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ args = sys.argv[1:] if argv is None else argv
16
+ if not args or args[0] != "show":
17
+ print("usage: python -m clashctl.config show", file=sys.stderr)
18
+ return 2
19
+
20
+ path = default_config_path()
21
+ print(f"# config path: {path}", file=sys.stderr)
22
+ print(f"# exists: {path.exists()}", file=sys.stderr)
23
+ cfg = load_config(path)
24
+ print(json.dumps(cfg.model_dump(mode="json", exclude_none=True), indent=2))
25
+ return 0
26
+
27
+
28
+ if __name__ == "__main__":
29
+ raise SystemExit(main())
@@ -0,0 +1,135 @@
1
+ """TOML-backed user config.
2
+
3
+ Lives at `~/.config/clashctl/config.toml` (resolved via platformdirs).
4
+ This is *clashctl's own* config — distinct from Clash's `/configs` payload
5
+ (`models.config.ClashConfig`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
14
+
15
+ from clashctl.state.sort import (
16
+ ConnSort,
17
+ ConnSortBy,
18
+ Order,
19
+ ProxySort,
20
+ ProxySortBy,
21
+ RuleSort,
22
+ RuleSortBy,
23
+ )
24
+
25
+
26
+ class _StrictModel(BaseModel):
27
+ model_config = ConfigDict(extra="ignore", validate_assignment=True)
28
+
29
+
30
+ class Server(_StrictModel):
31
+ """A Clash controller endpoint."""
32
+
33
+ name: str
34
+ url: str
35
+ secret: str | None = None
36
+
37
+ @field_validator("secret", mode="before")
38
+ @classmethod
39
+ def _empty_secret_to_none(cls, v: Any) -> Any:
40
+ if isinstance(v, str) and v == "":
41
+ return None
42
+ return v
43
+
44
+ @field_validator("url")
45
+ @classmethod
46
+ def _validate_url(cls, v: str) -> str:
47
+ parsed = urlparse(v)
48
+ if parsed.scheme not in ("http", "https"):
49
+ raise ValueError(f"url must be http(s): {v!r}")
50
+ if not parsed.netloc:
51
+ raise ValueError(f"url missing host: {v!r}")
52
+ return v
53
+
54
+
55
+ # --- per-list sort blocks --------------------------------------------------
56
+
57
+
58
+ class _SortBlock(_StrictModel):
59
+ by: str
60
+ order: Order = Order.Asc
61
+
62
+
63
+ class ProxySortConfig(_SortBlock):
64
+ by: ProxySortBy = ProxySortBy.Delay # type: ignore[assignment]
65
+
66
+
67
+ class RuleSortConfig(_SortBlock):
68
+ by: RuleSortBy = RuleSortBy.Payload # type: ignore[assignment]
69
+
70
+
71
+ class ConnSortConfig(_SortBlock):
72
+ by: ConnSortBy = ConnSortBy.Time # type: ignore[assignment]
73
+ order: Order = Order.Desc
74
+
75
+
76
+ class SortsConfig(_StrictModel):
77
+ proxies: ProxySortConfig = Field(default_factory=ProxySortConfig)
78
+ rules: RuleSortConfig = Field(default_factory=RuleSortConfig)
79
+ connections: ConnSortConfig = Field(default_factory=ConnSortConfig)
80
+
81
+ def to_runtime(self) -> tuple[ProxySort, RuleSort, ConnSort]:
82
+ return (
83
+ ProxySort(by=self.proxies.by, order=self.proxies.order),
84
+ RuleSort(by=self.rules.by, order=self.rules.order),
85
+ ConnSort(by=self.connections.by, order=self.connections.order),
86
+ )
87
+
88
+
89
+ # --- UI block --------------------------------------------------------------
90
+
91
+
92
+ class UiConfig(_StrictModel):
93
+ test_url: str = "http://www.gstatic.com/generate_204"
94
+ test_timeout_ms: int = 5000
95
+ log_buffer: int = 2000
96
+ traffic_history: int = 500
97
+ refresh_slow_secs: float = 5.0
98
+ refresh_fast_secs: float = 1.0
99
+ sort: SortsConfig = Field(default_factory=SortsConfig)
100
+
101
+
102
+ # --- top-level ------------------------------------------------------------
103
+
104
+
105
+ class AppConfig(_StrictModel):
106
+ """Persisted user config."""
107
+
108
+ using: str | None = None
109
+ servers: list[Server] = Field(default_factory=list)
110
+ ui: UiConfig = Field(default_factory=UiConfig)
111
+
112
+ def using_server(self) -> Server | None:
113
+ if self.using is None:
114
+ return None
115
+ for s in self.servers:
116
+ if s.url == self.using:
117
+ return s
118
+ return None
119
+
120
+ def use(self, url: str) -> None:
121
+ if not any(s.url == url for s in self.servers):
122
+ raise ValueError(f"server not found: {url}")
123
+ self.using = url
124
+
125
+ def add_server(self, server: Server) -> None:
126
+ if any(s.url == server.url for s in self.servers):
127
+ raise ValueError(f"server already exists: {server.url}")
128
+ self.servers.append(server)
129
+ if self.using is None:
130
+ self.using = server.url
131
+
132
+ def remove_server(self, url: str) -> None:
133
+ self.servers = [s for s in self.servers if s.url != url]
134
+ if self.using == url:
135
+ self.using = self.servers[0].url if self.servers else None
@@ -0,0 +1,56 @@
1
+ """Loading and saving the TOML config file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import tomli_w
11
+ from platformdirs import user_config_dir
12
+
13
+ from clashctl.config.schema import AppConfig
14
+
15
+ _APP_NAME = "clashctl"
16
+ _CONFIG_FILENAME = "config.toml"
17
+
18
+
19
+ def default_config_path() -> Path:
20
+ """Return the canonical config path, respecting `CLASHCTL_CONFIG_PATH`.
21
+
22
+ Falls back to `<user_config_dir>/clashctl/config.toml` (XDG on Linux,
23
+ AppData on Windows, ~/Library/Application Support on macOS).
24
+ """
25
+ override = os.environ.get("CLASHCTL_CONFIG_PATH")
26
+ if override:
27
+ return Path(override).expanduser()
28
+ return Path(user_config_dir(_APP_NAME)) / _CONFIG_FILENAME
29
+
30
+
31
+ def load_config(path: Path | None = None) -> AppConfig:
32
+ """Read and validate the config file. Returns defaults if missing."""
33
+ p = path or default_config_path()
34
+ if not p.exists():
35
+ return AppConfig()
36
+ raw = tomllib.loads(p.read_text(encoding="utf-8"))
37
+ return AppConfig.model_validate(raw)
38
+
39
+
40
+ def save_config(config: AppConfig, path: Path | None = None) -> Path:
41
+ """Atomically write `config` to `path`. Creates parent dirs as needed.
42
+
43
+ Returns the path written.
44
+ """
45
+ p = path or default_config_path()
46
+ p.parent.mkdir(parents=True, exist_ok=True)
47
+
48
+ # `model_dump(mode="json")` coerces enums to their string values, which
49
+ # is what tomli_w expects. `exclude_none=True` keeps the file tidy.
50
+ data: dict[str, Any] = config.model_dump(mode="json", exclude_none=True)
51
+ payload = tomli_w.dumps(data)
52
+
53
+ tmp = p.with_suffix(p.suffix + ".tmp")
54
+ tmp.write_text(payload, encoding="utf-8")
55
+ os.replace(tmp, p)
56
+ return p
@@ -0,0 +1,28 @@
1
+ from clashctl.models.config import ClashConfig
2
+ from clashctl.models.connection import Connection, Connections, Metadata
3
+ from clashctl.models.enums import LogLevel, Mode, ProxyType, RuleType
4
+ from clashctl.models.log import LogLine
5
+ from clashctl.models.proxy import Delay, History, Proxies, Proxy
6
+ from clashctl.models.rule import Rule, Rules
7
+ from clashctl.models.traffic import Traffic
8
+ from clashctl.models.version import Version
9
+
10
+ __all__ = [
11
+ "ClashConfig",
12
+ "Connection",
13
+ "Connections",
14
+ "Delay",
15
+ "History",
16
+ "LogLevel",
17
+ "LogLine",
18
+ "Metadata",
19
+ "Mode",
20
+ "Proxies",
21
+ "Proxy",
22
+ "ProxyType",
23
+ "Rule",
24
+ "RuleType",
25
+ "Rules",
26
+ "Traffic",
27
+ "Version",
28
+ ]
@@ -0,0 +1,24 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+ from pydantic.alias_generators import to_camel
3
+
4
+
5
+ class ClashModel(BaseModel):
6
+ """Base for Clash API models. Most endpoints use camelCase JSON keys."""
7
+
8
+ model_config = ConfigDict(
9
+ populate_by_name=True,
10
+ alias_generator=to_camel,
11
+ )
12
+
13
+
14
+ def _to_kebab(name: str) -> str:
15
+ return name.replace("_", "-")
16
+
17
+
18
+ class ClashKebabModel(BaseModel):
19
+ """Base for endpoints using kebab-case (notably `/configs`)."""
20
+
21
+ model_config = ConfigDict(
22
+ populate_by_name=True,
23
+ alias_generator=_to_kebab,
24
+ )
@@ -0,0 +1,37 @@
1
+ from typing import Any
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from clashctl.models.base import ClashKebabModel
6
+ from clashctl.models.enums import LogLevel, Mode
7
+
8
+
9
+ class ClashConfig(ClashKebabModel):
10
+ """Mirror of Clash's `/configs` payload (kebab-case keys)."""
11
+
12
+ port: int = 0
13
+ socks_port: int = 0
14
+ redir_port: int = 0
15
+ tproxy_port: int = 0
16
+ mixed_port: int = 0
17
+ allow_lan: bool = False
18
+ ipv6: bool = False
19
+ mode: Mode = Mode.Rule
20
+ log_level: LogLevel = LogLevel.Info
21
+ bind_address: str = ""
22
+ authentication: list[str] = Field(default_factory=list)
23
+
24
+ @field_validator("mode", mode="before")
25
+ @classmethod
26
+ def _coerce_mode(cls, v: Any) -> Any:
27
+ return Mode(v) if isinstance(v, str) else v
28
+
29
+ @field_validator("log_level", mode="before")
30
+ @classmethod
31
+ def _coerce_log_level(cls, v: Any) -> Any:
32
+ return LogLevel(v) if isinstance(v, str) else v
33
+
34
+ @field_validator("authentication", mode="before")
35
+ @classmethod
36
+ def _coerce_authentication(cls, v: Any) -> Any:
37
+ return [] if v is None else v