bykcli 1.0.0a1__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.
bykcli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """bykcli 包入口。"""
2
+
3
+ __version__ = "1.0.0a1"
bykcli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """支持 ``python -m bykcli`` 运行。"""
2
+
3
+ from bykcli.main import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
bykcli/api/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """面向子命令和插件的公开运行时 API。"""
2
+
3
+ from bykcli.api.context import CommandContext, pass_command_context, get_app_context
4
+ from bykcli.api.paths import (
5
+ PathItem,
6
+ PathProvider,
7
+ register_path_provider,
8
+ get_path_provider,
9
+ global_path_items,
10
+ )
11
+
12
+ from bykcli.api.network import (
13
+ get_private_networks,
14
+ ensure_port_available,
15
+ )
16
+
17
+ from bykcli.core.state import StateStore
18
+ from bykcli.infra.daemon import start_daemon
19
+
20
+ __all__ = [
21
+ # 上下文
22
+ "CommandContext",
23
+ "pass_command_context",
24
+ "get_app_context",
25
+ # 路径管理
26
+ "PathItem",
27
+ "PathProvider",
28
+ "register_path_provider",
29
+ "get_path_provider",
30
+ "global_path_items",
31
+ # 网络工具
32
+ "get_private_networks",
33
+ "ensure_port_available",
34
+ # 状态存储
35
+ "StateStore",
36
+ # 守护进程
37
+ "start_daemon",
38
+ ]
bykcli/api/context.py ADDED
@@ -0,0 +1,65 @@
1
+ """面向子命令的上下文封装。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from functools import update_wrapper
7
+ import logging
8
+ from typing import Any, Callable, TypeVar, cast
9
+
10
+ import click
11
+
12
+ from bykcli.app import CliState
13
+ from bykcli.core.context import AppContext, CommandContextLike
14
+ from bykcli.core.state import StateStore
15
+
16
+ F = TypeVar("F", bound=Callable[..., Any])
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class CommandContext(CommandContextLike):
21
+ """提供给子命令的便捷上下文。"""
22
+
23
+ name: str
24
+ app: AppContext
25
+ logger: logging.Logger
26
+ _state_cache: dict[str, StateStore] = field(default_factory=dict, init=False, repr=False)
27
+
28
+ def state(self, name: str = "state") -> StateStore:
29
+ """获取当前命令的专属存储。"""
30
+ if name not in self._state_cache:
31
+ self._state_cache[name] = self.app.command_store(self.name, name)
32
+ return self._state_cache[name]
33
+
34
+ def _get_group_name(ctx: click.Context) -> str:
35
+ """获取二级命令名"""
36
+ node = ctx
37
+ while node.parent and node.parent.parent is not None:
38
+ node = node.parent
39
+ return node.command.name or "unknown"
40
+
41
+ def build_command_context(ctx: click.Context) -> CommandContext:
42
+ """根据当前 Click 上下文构建命令上下文"""
43
+ state = cast(CliState, ctx.obj)
44
+ command_name = _get_group_name(ctx)
45
+ return CommandContext(
46
+ name=command_name,
47
+ app=state.context,
48
+ logger=state.context.get_command_logger(command_name),
49
+ )
50
+
51
+
52
+ def pass_command_context(func: F) -> F:
53
+ """向子命令注入便捷上下文。"""
54
+
55
+ @click.pass_context
56
+ def new_func(ctx: click.Context, *args: Any, **kwargs: Any) -> Any:
57
+ return ctx.invoke(func, build_command_context(ctx), *args, **kwargs)
58
+
59
+ return cast(F, update_wrapper(new_func, func))
60
+
61
+
62
+ def get_app_context() -> AppContext:
63
+ """获取当前应用上下文(用于后台进程启动等场景)。"""
64
+ from bykcli.runtime import build_runtime
65
+ return build_runtime()
bykcli/api/network.py ADDED
@@ -0,0 +1,82 @@
1
+ """网络工具函数。"""
2
+
3
+ import socket
4
+ import psutil
5
+ from typing import List, Dict
6
+
7
+
8
+ # 网络接口分类规则:(关键词列表,类型名称,是否虚拟接口,优先级)
9
+ IFACE_RULES = [
10
+ (['vmware', 'vmnet'], 'vmware', True, 30),
11
+ (['vbox', 'virtualbox'], 'virtualbox', True, 30),
12
+ (['docker', 'wsl'], 'container', True, 40),
13
+ (['bluetooth'], 'bluetooth', True, 60),
14
+ (['ethernet', '以太网'], 'ethernet', False, 10),
15
+ (['wlan', 'wi-fi', '无线'], 'wifi', False, 10),
16
+ (['loopback'], 'loopback', True, 100),
17
+ ]
18
+
19
+
20
+ def detect_iface_type(iface: str) -> tuple[str, bool, int]:
21
+ """检测网络接口类型。"""
22
+ lname = iface.lower()
23
+ for keywords, t, virtual, prio in IFACE_RULES:
24
+ if any(k in lname for k in keywords):
25
+ return t, virtual, prio
26
+ return 'unknown', False, 50
27
+
28
+
29
+ def get_private_networks() -> List[Dict]:
30
+ """获取所有局域网 IP 地址信息。"""
31
+ results = []
32
+ interfaces = psutil.net_if_addrs()
33
+
34
+ for iface, addrs in interfaces.items():
35
+ ips = []
36
+ iface_type, is_virtual, priority = detect_iface_type(iface)
37
+
38
+ for addr in addrs:
39
+ if addr.family != socket.AF_INET:
40
+ continue
41
+
42
+ ip = addr.address
43
+
44
+ # 跳过回环地址和链路本地地址
45
+ if ip.startswith('127.'):
46
+ continue
47
+ if ip.startswith('169.254.'):
48
+ continue
49
+ # 只保留私有 IP 地址
50
+ if ip.startswith(('10.', '192.168.', '172.')):
51
+ ips.append(ip)
52
+
53
+ if ips:
54
+ results.append({
55
+ "iface": iface,
56
+ "ips": ips,
57
+ "type": iface_type,
58
+ "virtual": is_virtual,
59
+ "priority": priority
60
+ })
61
+
62
+ # 按优先级排序(数字越小优先级越高)
63
+ results.sort(key=lambda x: x['priority'])
64
+
65
+ # 如果没有找到任何局域网 IP,返回 localhost
66
+ if not results:
67
+ results.append({
68
+ "iface": "localhost",
69
+ "ips": ["127.0.0.1"],
70
+ "type": "loopback",
71
+ "virtual": True,
72
+ "priority": 100
73
+ })
74
+
75
+ return results
76
+
77
+
78
+ def ensure_port_available(port: int, host: str = "0.0.0.0") -> None:
79
+ """检查端口是否可用。"""
80
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
81
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
82
+ s.bind((host, int(port)))
bykcli/api/paths.py ADDED
@@ -0,0 +1,31 @@
1
+ """路径展示注册与渲染。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from bykcli.core.context import AppContext
8
+
9
+ PathItem = tuple[str, str]
10
+ PathProvider = Callable[[AppContext], list[PathItem]]
11
+
12
+ _PATH_PROVIDERS: dict[str, PathProvider] = {}
13
+
14
+
15
+ def register_path_provider(command_name: str, provider: PathProvider) -> None:
16
+ """注册子命令路径提供器。"""
17
+ _PATH_PROVIDERS[command_name] = provider
18
+
19
+
20
+ def get_path_provider(command_name: str) -> PathProvider | None:
21
+ """获取某个子命令的路径提供器。"""
22
+ return _PATH_PROVIDERS.get(command_name)
23
+
24
+
25
+ def global_path_items(context: AppContext) -> list[PathItem]:
26
+ """返回默认展示的全局路径。"""
27
+ return [
28
+ ("CLI Home", str(context.paths.root_dir)),
29
+ ("Alias File", str(context.paths.alias_file)),
30
+ ("Logs Directory", str(context.paths.logs_dir)),
31
+ ]
bykcli/app.py ADDED
@@ -0,0 +1,181 @@
1
+ """CLI 应用装配。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import click
8
+
9
+ from bykcli.core.context import AppContext
10
+ from bykcli.infra.aliases import AliasAwareGroup
11
+ from bykcli.infra.daemon import kill_daemon_callback
12
+ from bykcli.infra.logging import setup_logging
13
+ from bykcli.infra.registry import register_builtin_plugins, register_plugins
14
+ from bykcli.infra.view import render_dashboard
15
+ from bykcli.runtime import build_runtime
16
+ import bykcli.plugins as builtin_plugins
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class CliState:
21
+ """Click 根命令共享状态。"""
22
+
23
+ context: AppContext
24
+
25
+
26
+ def version_callback(
27
+ ctx: click.Context,
28
+ _param: click.Parameter,
29
+ value: bool,
30
+ ) -> None:
31
+ """Show version and exit."""
32
+ if not value or ctx.resilient_parsing:
33
+ return
34
+
35
+ from bykcli.infra.view import format_version_line
36
+ from rich.console import Console
37
+
38
+ app_context: AppContext = ctx.obj.context if ctx.obj else build_runtime()
39
+ console = Console()
40
+ console.print(format_version_line(app_context.environment))
41
+ ctx.exit()
42
+
43
+
44
+ def list_plugins_callback(
45
+ ctx: click.Context,
46
+ _param: click.Parameter,
47
+ value: bool,
48
+ ) -> None:
49
+ """List all external plugins and their versions."""
50
+ if not value or ctx.resilient_parsing:
51
+ return
52
+
53
+ from importlib.metadata import entry_points, version, PackageNotFoundError, distributions
54
+ from rich.console import Console
55
+ from rich.table import Table
56
+ from rich import box
57
+
58
+ console = Console()
59
+
60
+ try:
61
+ plugin_entries = entry_points(group="bykcli.plugins")
62
+ except TypeError:
63
+ plugin_entries = entry_points().get("bykcli.plugins", [])
64
+
65
+ if not plugin_entries:
66
+ console.print("[dim]No external plugins installed.[/dim]")
67
+ ctx.exit()
68
+
69
+ table = Table(
70
+ title="[bold cyan]External Plugins[/bold cyan]",
71
+ box=box.ROUNDED,
72
+ border_style="bright_blue",
73
+ header_style="bold bright_magenta",
74
+ show_lines=True,
75
+ padding=(0, 2),
76
+ collapse_padding=False,
77
+ )
78
+ table.add_column("Package Name", style="bold cyan", no_wrap=True)
79
+ table.add_column("Version", style="bold green", justify="center")
80
+ table.add_column("Entry Point", style="dim", overflow="fold")
81
+
82
+ for entry in plugin_entries:
83
+ pkg_name = entry.name
84
+ pkg_version = "unknown"
85
+
86
+ candidates = [entry.name]
87
+
88
+ if entry.name.startswith("bykcli"):
89
+ candidates.append(f"bykcli-{entry.name[5:]}")
90
+ else:
91
+ candidates.append(f"bykcli-{entry.name}")
92
+
93
+ candidates.append(entry.name.replace("-", "_"))
94
+ candidates.append(entry.name.replace("_", "-"))
95
+
96
+ try:
97
+ module_path = entry.value.split(":")[0]
98
+ top_level_pkg = module_path.split(".")[0]
99
+ candidates.append(top_level_pkg)
100
+ if not top_level_pkg.startswith("bykcli"):
101
+ candidates.append(f"bykcli-{top_level_pkg}")
102
+ except (IndexError):
103
+ pass
104
+
105
+ found = False
106
+ for candidate in candidates:
107
+ if not candidate:
108
+ continue
109
+ try:
110
+ pkg_version = version(candidate)
111
+ pkg_name = candidate
112
+ found = True
113
+ break
114
+ except PackageNotFoundError:
115
+ continue
116
+
117
+ table.add_row(pkg_name, pkg_version, entry.value)
118
+
119
+ click.echo()
120
+ console.print(table)
121
+ click.echo()
122
+ ctx.exit()
123
+
124
+
125
+ def create_cli() -> click.Group:
126
+ """创建根 CLI 对象。"""
127
+
128
+ runtime: AppContext | None = None
129
+
130
+ def get_runtime() -> AppContext:
131
+ nonlocal runtime
132
+ if runtime is None:
133
+ runtime = build_runtime()
134
+ return runtime
135
+
136
+ temp_runtime = build_runtime()
137
+ setup_logging(temp_runtime)
138
+
139
+ @click.group(
140
+ cls=AliasAwareGroup,
141
+ context_settings={"help_option_names": ["-h", "--help"]},
142
+ invoke_without_command=True,
143
+ )
144
+ @click.option(
145
+ "--version",
146
+ "-v",
147
+ is_flag=True,
148
+ callback=version_callback,
149
+ expose_value=False,
150
+ is_eager=True,
151
+ help="Show version and exit.",
152
+ )
153
+ @click.option(
154
+ "--kill",
155
+ "-k",
156
+ type=str,
157
+ callback=kill_daemon_callback,
158
+ expose_value=False,
159
+ is_eager=True,
160
+ help='Kill background daemon processes. Use "all" to kill all or specify PID.',
161
+ )
162
+ @click.option(
163
+ "--list",
164
+ "-l",
165
+ is_flag=True,
166
+ callback=list_plugins_callback,
167
+ expose_value=False,
168
+ is_eager=True,
169
+ help="List all external plugins and their versions.",
170
+ )
171
+ @click.pass_context
172
+ def cli(ctx: click.Context) -> None:
173
+ ctx.obj = CliState(context=get_runtime())
174
+ ctx.obj.context.logger.info("BYK CLI v%s started", ctx.obj.context.version)
175
+ if ctx.invoked_subcommand is None:
176
+ render_dashboard(ctx.obj.context, cli)
177
+
178
+ cli.runtime_provider = get_runtime
179
+ register_builtin_plugins(cli, builtin_plugins)
180
+ register_plugins(cli)
181
+ return cli
@@ -0,0 +1,14 @@
1
+ """核心能力模块。"""
2
+
3
+ from bykcli.core.context import AppContext, CommandContextLike
4
+ from bykcli.core.environment import EnvironmentInfo
5
+ from bykcli.core.persistence import PathLayout
6
+ from bykcli.core.state import StateStore
7
+
8
+ __all__ = [
9
+ "AppContext",
10
+ "CommandContextLike",
11
+ "EnvironmentInfo",
12
+ "PathLayout",
13
+ "StateStore",
14
+ ]
bykcli/core/context.py ADDED
@@ -0,0 +1,50 @@
1
+ """CLI 运行时上下文。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import logging
7
+ from typing import TYPE_CHECKING, Protocol
8
+
9
+ from bykcli.core.environment import EnvironmentInfo
10
+ from bykcli.core.persistence import PathLayout
11
+
12
+ if TYPE_CHECKING:
13
+ from bykcli.core.state import StateStore
14
+
15
+
16
+ class CommandContextLike(Protocol):
17
+ """命令上下文协议。"""
18
+
19
+ name: str
20
+ app: "AppContext"
21
+ logger: logging.Logger
22
+
23
+ def state(self, name: str = "state") -> "StateStore": ...
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class AppContext:
28
+ """子命令共享的核心上下文。"""
29
+
30
+ app_name: str
31
+ version: str
32
+ paths: PathLayout
33
+ environment: EnvironmentInfo
34
+ logger: logging.Logger
35
+
36
+ def command_store(
37
+ self,
38
+ command_name: str,
39
+ name: str = "state",
40
+ ) -> StateStore:
41
+ """返回某个子命令的状态存储。"""
42
+ raise NotImplementedError
43
+
44
+ def store(self, name: str = "byk.config") -> StateStore:
45
+ """返回应用级共享状态存储。"""
46
+ raise NotImplementedError
47
+
48
+ def get_command_logger(self, command_name: str) -> logging.Logger:
49
+ """获取命令专属 logger。"""
50
+ raise NotImplementedError
@@ -0,0 +1,29 @@
1
+ """运行环境信息。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import platform
7
+ import sys
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class EnvironmentInfo:
12
+ """CLI 运行环境信息。"""
13
+
14
+ app_name: str
15
+ version: str
16
+ python_version: str
17
+ executable: str
18
+ platform_name: str
19
+
20
+
21
+ def collect_environment(app_name: str, version: str) -> EnvironmentInfo:
22
+ """收集当前运行环境。"""
23
+ return EnvironmentInfo(
24
+ app_name=app_name,
25
+ version=version,
26
+ python_version=platform.python_version(),
27
+ executable=sys.executable,
28
+ platform_name=platform.platform(),
29
+ )
bykcli/core/errors.py ADDED
@@ -0,0 +1,7 @@
1
+ """统一异常定义。"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CliError(RuntimeError):
7
+ """面向终端用户的业务异常。"""
@@ -0,0 +1,54 @@
1
+ """持久化路径布局。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class PathLayout:
11
+ """持久化目录布局数据结构。"""
12
+
13
+ root_dir: Path
14
+ state_dir: Path
15
+ logs_dir: Path
16
+ runtime_dir: Path
17
+ alias_file: Path
18
+ app_log_file: Path
19
+ daemon_dir: Path
20
+
21
+ @staticmethod
22
+ def _validate_safe_name(value: str, field_name: str) -> str:
23
+ """校验名称安全性,防止路径穿越和脏数据文件名。"""
24
+ normalized = value.strip()
25
+ if not normalized:
26
+ raise ValueError(f"{field_name} 不能为空")
27
+ if normalized in {".", ".."}:
28
+ raise ValueError(f"{field_name} 不能为 '.' 或 '..'")
29
+ if "/" in normalized or "\\" in normalized:
30
+ raise ValueError(f"{field_name} 不能包含路径分隔符")
31
+ return normalized
32
+
33
+ def command_state_dir(self, command_name: str) -> Path:
34
+ """返回子命令专属目录。"""
35
+ safe_command_name = self._validate_safe_name(command_name, "command_name")
36
+ path = self.state_dir / safe_command_name
37
+ path.mkdir(parents=True, exist_ok=True)
38
+ return path
39
+
40
+ def command_state_file(self, command_name: str, *parts: str) -> Path:
41
+ """返回子命令专属文件路径。"""
42
+ target = self.command_state_dir(command_name)
43
+ for part in parts:
44
+ safe_part = self._validate_safe_name(part, "part")
45
+ target = target / safe_part
46
+ target.parent.mkdir(parents=True, exist_ok=True)
47
+ return target
48
+
49
+ def state_file(self, name: str) -> Path:
50
+ """返回应用级状态文件路径。"""
51
+ safe_name = self._validate_safe_name(name, "name")
52
+ path = self.state_dir / f"{safe_name}.json"
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ return path
bykcli/core/state.py ADDED
@@ -0,0 +1,20 @@
1
+ """子命令状态存储协议。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Protocol
7
+
8
+
9
+ class StateStore(Protocol):
10
+ """状态存储协议。"""
11
+
12
+ path: Path
13
+
14
+ def load(self) -> dict[str, Any]: ...
15
+ def save(self, data: dict[str, Any]) -> dict[str, Any]: ...
16
+ def get(self, key: str, default: Any = None) -> Any: ...
17
+ def set(self, key: str, value: Any) -> dict[str, Any]: ...
18
+ def update(self, values: dict[str, Any]) -> dict[str, Any]: ...
19
+ def delete(self, key: str) -> dict[str, Any]: ...
20
+ def clear(self) -> dict[str, Any]: ...
File without changes