redisctl 0.1.0__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.
@@ -0,0 +1,18 @@
1
+ .env
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ dist/
6
+ *.egg-info/
7
+ .ruff_cache/
8
+ .playwright-mcp/
9
+ .claude/settings.local.json
10
+ .DS_Store
11
+
12
+ # 本地 MCP 配置:各人自备、可含个人令牌,不进仓库
13
+ .mcp.json
14
+ .redis-cluster/
15
+
16
+ # 本机运行的配置(由 config/gateway.example.toml 派生),不进仓库
17
+ config/gateway.toml
18
+ .env.secret
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: redisctl
3
+ Version: 0.1.0
4
+ Summary: Redis MCP Gateway 命令行客户端 —— 从不直连 Redis
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx>=0.28.0
7
+ Requires-Dist: typer>=0.15.0
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "redisctl"
3
+ version = "0.1.0"
4
+ description = "Redis MCP Gateway 命令行客户端 —— 从不直连 Redis"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "httpx>=0.28.0",
8
+ "typer>=0.15.0",
9
+ ]
10
+
11
+ [project.scripts]
12
+ redisctl = "redisctl.main:main"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["redisctl"]
@@ -0,0 +1,7 @@
1
+ """Redis MCP Gateway 命令行客户端。
2
+
3
+ **从不直连 Redis。** 所有调用都走网关的 HTTP 接口,
4
+ 凭据留在网关侧;本包不依赖任何 Redis 客户端。
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,214 @@
1
+ """由**发现到的**操作清单构造 typer 应用。
2
+
3
+ 与网关侧的 MCP 工具生成同理:typer 需要真实的函数签名来推导参数,
4
+ 所以按操作声明生成带显式形参的函数,而不是用一个 `**kwargs` 的通用分发。
5
+
6
+ 差别在于操作清单是**运行时从网关拉来的**,不是编译期从注册表导入的 ——
7
+ 这样 CLI 与网关不会各有一份必然分叉的清单(章程原则 IV)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from typing import Any
15
+
16
+ import typer
17
+
18
+ from .client import GatewayError, call
19
+ from .discovery import Operation, Param
20
+
21
+ #: CLI 自带的命令名。发现到的操作**不得**与它们重名,
22
+ #: 否则子命令会被静默覆盖 —— 那种错很难查。
23
+ BUILTIN_COMMANDS = frozenset({"version", "operations", "refresh"})
24
+
25
+ #: 网关侧的参数类型 → typer 能推导的 Python 注解
26
+ _TYPER_ANNOTATION = {
27
+ "str": "str",
28
+ "int": "int",
29
+ "float": "float",
30
+ "bool": "bool",
31
+ # 复杂类型在命令行上以 JSON 文本传入,由 _parse_value 解析
32
+ "dict": "str",
33
+ "list": "str",
34
+ }
35
+
36
+
37
+ def _parse_value(param: Param, raw: Any) -> Any:
38
+ """把命令行上的字符串转成网关期望的类型。"""
39
+ if raw is None:
40
+ return None
41
+ if param.type in {"dict", "list"}:
42
+ try:
43
+ return json.loads(raw)
44
+ except (TypeError, ValueError) as exc:
45
+ example = '{"a":1}' if param.type == "dict" else '["a","b"]'
46
+ raise typer.BadParameter(
47
+ f"需要 JSON 文本,例如 {example}", param_hint=f"--{param.name}"
48
+ ) from exc
49
+ return raw
50
+
51
+
52
+ def _option_source(param: Param) -> str:
53
+ """生成一个 typer.Option 的源码片段。"""
54
+ flag = param.name.replace("_", "-")
55
+ help_text = repr(param.description or "")
56
+ if param.required:
57
+ default = "..."
58
+ elif param.default is None:
59
+ default = "None"
60
+ else:
61
+ default = repr(param.default)
62
+ return f"typer.Option({default}, '--{flag}', help={help_text})"
63
+
64
+
65
+ def _make_command(operation: Operation):
66
+ """按操作声明生成一个 typer 命令函数。"""
67
+ pieces: list[str] = []
68
+ for param in operation.params:
69
+ pieces.append(f"{param.name}: {_TYPER_ANNOTATION[param.type]} = {_option_source(param)}")
70
+ pieces.append(
71
+ "environment: str | None = typer.Option("
72
+ "None, '--environment', '-e', "
73
+ "help='目标环境(如 dev / fat / prod)。省略则用网关配置的默认环境;两者都没有时会报错。')"
74
+ )
75
+
76
+ source = (
77
+ f"def {operation.name}({', '.join(pieces)}):\n"
78
+ f" raw = {{k: v for k, v in locals().items() if k != 'environment'}}\n"
79
+ f" return _invoke({operation.name!r}, raw, environment)\n"
80
+ )
81
+
82
+ def _invoke(name: str, raw: dict, environment: str | None) -> None:
83
+ op = _OPERATIONS_BY_NAME[name]
84
+ params = {}
85
+ for param in op.params:
86
+ value = raw.get(param.name)
87
+ if value is None:
88
+ continue
89
+ params[param.name] = _parse_value(param, value)
90
+ _display(name, params, environment, write=op.write)
91
+
92
+ namespace: dict[str, Any] = {"_invoke": _invoke, "typer": typer}
93
+ exec(compile(source, f"<generated:{operation.name}>", "exec"), namespace)
94
+ fn = namespace[operation.name]
95
+ fn.__doc__ = _help_text(operation)
96
+ return fn
97
+
98
+
99
+ def _help_text(operation: Operation) -> str:
100
+ suffix = "【写操作,只读环境会被拒绝】" if operation.write else ""
101
+ return f"{operation.summary}{suffix}\n\n集群行为:{operation.cluster_note}"
102
+
103
+
104
+ def _display(name: str, params: dict, environment: str | None, *, write: bool) -> None:
105
+ try:
106
+ body = call(name, params, environment, write=write)
107
+ except GatewayError as exc:
108
+ typer.secho(f"[{exc.code}] {exc.message}", fg=typer.colors.RED, err=True)
109
+ if exc.hint:
110
+ typer.secho(f"提示:{exc.hint}", fg=typer.colors.YELLOW, err=True)
111
+ raise typer.Exit(code=1) from exc
112
+ typer.echo(json.dumps(body, ensure_ascii=False, indent=2, default=str))
113
+
114
+
115
+ #: 由 build_app 填充。生成出来的命令函数通过它按名字回查操作声明。
116
+ _OPERATIONS_BY_NAME: dict[str, Operation] = {}
117
+
118
+
119
+ def build_app(operations: list[Operation]) -> typer.Typer:
120
+ """由发现到的操作清单构造 typer 应用。"""
121
+ global _OPERATIONS_BY_NAME
122
+ _OPERATIONS_BY_NAME = {op.name: op for op in operations}
123
+
124
+ collisions = BUILTIN_COMMANDS & set(_OPERATIONS_BY_NAME)
125
+ if collisions:
126
+ # 静默覆盖会让某个命令凭空消失,而这在远端很难排查
127
+ typer.secho(
128
+ f"网关的操作清单与 CLI 自带命令重名:{sorted(collisions)}。"
129
+ "请先把这些操作改名。",
130
+ fg=typer.colors.RED, err=True,
131
+ )
132
+ raise typer.Exit(code=2)
133
+
134
+ built = typer.Typer(
135
+ name="redisctl",
136
+ help=(
137
+ "Redis MCP Gateway 命令行客户端。\n\n"
138
+ "所有调用都经过网关的护栏、环境路由与审计 —— 本工具**从不直连 Redis**,"
139
+ "也不持有 Redis 凭据。"
140
+ ),
141
+ no_args_is_help=True,
142
+ add_completion=False,
143
+ )
144
+
145
+ for operation in operations:
146
+ built.command(name=operation.name, help=_help_text(operation))(_make_command(operation))
147
+
148
+ _register_builtins(built, operations)
149
+ return built
150
+
151
+
152
+ def _register_builtins(built: typer.Typer, operations: list[Operation]) -> None:
153
+ """注册 CLI 自带的三个命令。"""
154
+
155
+ @built.command(name="version", help="显示 redisctl 版本。")
156
+ def version() -> None:
157
+ from . import __version__
158
+
159
+ typer.echo(f"redisctl {__version__}")
160
+
161
+ @built.command(name="refresh", help="强制重新从网关拉取操作清单(刷新本地缓存)。")
162
+ def refresh() -> None:
163
+ from .client import gateway_token, gateway_url
164
+ from .discovery import DiscoveryError, fetch_operations, write_cache
165
+
166
+ try:
167
+ fetched = fetch_operations(gateway_url(), gateway_token())
168
+ except DiscoveryError as exc:
169
+ typer.secho(f"刷新失败:{exc}", fg=typer.colors.RED, err=True)
170
+ raise typer.Exit(code=1) from exc
171
+
172
+ write_cache(gateway_url(), fetched)
173
+ typer.echo(f"已刷新,网关当前提供 {len(fetched)} 个操作。")
174
+ if len(fetched) != len(operations):
175
+ # 本次进程的命令列表已经构造完了,改动从下次调用开始生效 ——
176
+ # 不说清楚的话,用户会以为刷新没生效
177
+ typer.secho(
178
+ f"注意:本次运行已经按 {len(operations)} 个操作构造好命令,"
179
+ "新的清单从下次执行起生效。",
180
+ fg=typer.colors.YELLOW,
181
+ )
182
+
183
+ @built.command(name="operations", help="列出从网关发现到的操作(排查用)。")
184
+ def list_operations(
185
+ only_writes: bool = typer.Option(False, "--writes", help="只列写操作"),
186
+ as_json: bool = typer.Option(False, "--json", help="输出原始 JSON"),
187
+ ) -> None:
188
+ selected = [op for op in operations if (op.write if only_writes else True)]
189
+ if as_json:
190
+ typer.echo(
191
+ json.dumps([op.to_payload() for op in selected], ensure_ascii=False, indent=2)
192
+ )
193
+ return
194
+ typer.echo(f"从网关发现 {len(operations)} 个操作:\n")
195
+ for op in selected:
196
+ kind = "写" if op.write else "读"
197
+ required = " ".join(
198
+ f"--{p.name.replace('_', '-')}" for p in op.params if p.required
199
+ )
200
+ typer.echo(f" [{kind}] {op.name:<28} {required}")
201
+
202
+
203
+ def run(operations: list[Operation]) -> None:
204
+ """构造应用并交给 typer 运行。"""
205
+ build_app(operations)()
206
+
207
+
208
+ def warn(message: str) -> None:
209
+ typer.secho(f"提示:{message}", fg=typer.colors.YELLOW, err=True)
210
+
211
+
212
+ def fail(message: str, code: int = 2) -> None:
213
+ typer.secho(message, fg=typer.colors.RED, err=True)
214
+ sys.exit(code)
@@ -0,0 +1,108 @@
1
+ """调用网关的 HTTP 客户端。
2
+
3
+ **只跟网关说话,从不连 Redis。** 本包不依赖任何 Redis 客户端 ——
4
+ 它不知道集群在哪,也不持有 Redis 凭据。这是这套设计的核心:
5
+ Redis 集群可以放在开发者完全不可达的私有子网里。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ DEFAULT_TIMEOUT = 30.0
16
+
17
+
18
+ class GatewayError(Exception):
19
+ """网关返回了业务错误。带错误码与提示,供 CLI 直接展示。"""
20
+
21
+ def __init__(self, code: str, message: str, hint: str | None = None) -> None:
22
+ super().__init__(message)
23
+ self.code = code
24
+ self.message = message
25
+ self.hint = hint
26
+
27
+
28
+ def gateway_url() -> str:
29
+ url = (os.getenv("REDISCTL_API_URL") or "").strip()
30
+ if not url:
31
+ raise SystemExit(
32
+ "缺少环境变量 REDISCTL_API_URL(网关地址,"
33
+ "例如 https://host/mcp-gateway/redis/api)"
34
+ )
35
+ return url.rstrip("/")
36
+
37
+
38
+ def gateway_token() -> str:
39
+ token = (os.getenv("REDISCTL_TOKEN") or "").strip()
40
+ if not token:
41
+ raise SystemExit(
42
+ "缺少环境变量 REDISCTL_TOKEN(网关令牌)。\n"
43
+ "注意:不要用含 TOKEN / KEY / SECRET / AUTH 的变量名承载它 —— "
44
+ "某些客户端会把这类变量读成空字符串且不报警。"
45
+ )
46
+ return token
47
+
48
+
49
+ def caller_credential_headers() -> dict[str, str]:
50
+ """调用方自带的 Redis 身份凭据。
51
+
52
+ 这是**转发**给网关的,不是本地用来连 Redis 的 —— 网关收到后用这个身份
53
+ 去操作 Redis,让操作在 Redis 侧归因到你本人。
54
+
55
+ 只在不为空时才带上:头存在但值为空会被网关判为畸形并拒绝(那是刻意的,
56
+ 因为空值通常意味着环境变量没导出,静默回落会造成「成功但归因错了」)。
57
+ """
58
+ headers: dict[str, str] = {}
59
+ username = (os.getenv("REDISCTL_REDIS_USERNAME") or "").strip()
60
+ password = os.getenv("REDISCTL_REDIS_PASSWORD") or ""
61
+ if username:
62
+ headers["X-Redis-Username"] = username
63
+ if password:
64
+ headers["X-Redis-Password"] = password
65
+ return headers
66
+
67
+
68
+ def call(
69
+ operation_name: str,
70
+ params: dict[str, Any],
71
+ environment: str | None,
72
+ *,
73
+ write: bool,
74
+ timeout: float = DEFAULT_TIMEOUT,
75
+ ) -> dict:
76
+ """调用网关的一个操作,返回响应体。
77
+
78
+ 读用 GET、写用 POST —— 与网关的 REST 契约一致。
79
+ """
80
+ base = gateway_url()
81
+ if environment:
82
+ params = {**params, "environment": environment}
83
+
84
+ headers = {"Authorization": f"Bearer {gateway_token()}", **caller_credential_headers()}
85
+
86
+ try:
87
+ with httpx.Client(timeout=timeout) as client:
88
+ if write:
89
+ response = client.post(f"{base}/{operation_name}", json=params, headers=headers)
90
+ else:
91
+ response = client.get(f"{base}/{operation_name}", params=params, headers=headers)
92
+ except httpx.HTTPError as exc:
93
+ raise GatewayError("connection_failed", f"无法连接网关:{exc}") from exc
94
+
95
+ try:
96
+ body = response.json()
97
+ except ValueError as exc:
98
+ raise GatewayError(
99
+ "invalid_response", f"网关返回了非 JSON 响应(HTTP {response.status_code})"
100
+ ) from exc
101
+
102
+ if response.status_code >= 400 or not body.get("ok", False):
103
+ raise GatewayError(
104
+ body.get("error", "error"),
105
+ body.get("message", "网关返回了错误"),
106
+ body.get("hint"),
107
+ )
108
+ return body
@@ -0,0 +1,234 @@
1
+ """运行时发现网关的操作清单,并缓存到本地。
2
+
3
+ ## 为什么是「运行时发现」而不是「把注册表复制一份」
4
+
5
+ 复制两份清单必然分叉,而网关的护栏正建立在注册表的声明之上
6
+ (章程原则 IV)。让 CLI 在运行时从网关拉取,单一事实来源得以保持。
7
+
8
+ 额外好处:网关返回的是**能力探测之后**注册的操作,所以 CLI 永远只暴露
9
+ 网关实际提供的能力 —— 本机集群没有 JSON/Search 模块时是 36 个,
10
+ 上游装了模块就是 40 个,CLI 自动跟上,不会出现
11
+ 「CLI 里有这条命令,但网关根本没有」的错位。
12
+
13
+ ## 缓存策略
14
+
15
+ **网络优先,失败回退缓存。** 缓存有 TTL(默认 1 小时),陈旧就重新拉;
16
+ 拉不到时若有旧缓存就照旧用,并在 stderr 给个提示 ——
17
+ 命令行工具不该因为一次网络抖动就完全不能用。
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ import httpx
30
+
31
+ DEFAULT_TTL_SECONDS = 3600
32
+ #: 缓存的格式版本。格式变了要让旧缓存失效,而不是解析出错后报莫名其妙的 KeyError。
33
+ CACHE_SCHEMA_VERSION = 1
34
+
35
+
36
+ class DiscoveryError(Exception):
37
+ """发现操作清单失败,且没有可用缓存。"""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Param:
42
+ name: str
43
+ type: str
44
+ required: bool
45
+ default: object = None
46
+ description: str = ""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Operation:
51
+ name: str
52
+ category: str
53
+ write: bool
54
+ summary: str
55
+ cluster_note: str
56
+ params: tuple[Param, ...] = field(default_factory=tuple)
57
+ requires: str | None = None
58
+
59
+ @classmethod
60
+ def from_payload(cls, raw: dict) -> Operation:
61
+ return cls(
62
+ name=raw["name"],
63
+ category=raw.get("category", ""),
64
+ write=bool(raw.get("write", False)),
65
+ summary=raw.get("summary", ""),
66
+ cluster_note=raw.get("cluster_note", ""),
67
+ requires=raw.get("requires"),
68
+ params=tuple(
69
+ Param(
70
+ name=p["name"],
71
+ type=p.get("type", "str"),
72
+ required=bool(p.get("required", False)),
73
+ default=p.get("default"),
74
+ description=p.get("description", ""),
75
+ )
76
+ for p in raw.get("params", [])
77
+ ),
78
+ )
79
+
80
+ def to_payload(self) -> dict:
81
+ return {
82
+ "name": self.name,
83
+ "category": self.category,
84
+ "write": self.write,
85
+ "summary": self.summary,
86
+ "cluster_note": self.cluster_note,
87
+ "requires": self.requires,
88
+ "params": [
89
+ {
90
+ "name": p.name,
91
+ "type": p.type,
92
+ "required": p.required,
93
+ "default": p.default,
94
+ "description": p.description,
95
+ }
96
+ for p in self.params
97
+ ],
98
+ }
99
+
100
+
101
+ def cache_dir() -> Path:
102
+ base = os.getenv("XDG_CACHE_HOME") or (Path.home() / ".cache")
103
+ return Path(base) / "redisctl"
104
+
105
+
106
+ def cache_path(base_url: str) -> Path:
107
+ """按网关地址分别缓存 —— 不同网关的操作集可能完全不同。"""
108
+ digest = hashlib.sha256(base_url.encode()).hexdigest()[:16]
109
+ return cache_dir() / f"operations-{digest}.json"
110
+
111
+
112
+ def _ttl_seconds() -> int:
113
+ raw = (os.getenv("REDISCTL_CACHE_TTL") or "").strip()
114
+ if not raw:
115
+ return DEFAULT_TTL_SECONDS
116
+ try:
117
+ return max(0, int(raw))
118
+ except ValueError:
119
+ return DEFAULT_TTL_SECONDS
120
+
121
+
122
+ def read_cache(base_url: str) -> tuple[list[Operation], float] | None:
123
+ """读缓存,返回 (操作列表, 抓取时间戳)。没有或不可用则返回 None。"""
124
+ path = cache_path(base_url)
125
+ if not path.is_file():
126
+ return None
127
+ try:
128
+ payload = json.loads(path.read_text(encoding="utf-8"))
129
+ except (OSError, ValueError):
130
+ return None
131
+
132
+ if payload.get("schema_version") != CACHE_SCHEMA_VERSION:
133
+ return None
134
+ if payload.get("base_url") != base_url:
135
+ return None
136
+ try:
137
+ operations = [Operation.from_payload(o) for o in payload["operations"]]
138
+ except (KeyError, TypeError, ValueError):
139
+ return None
140
+ return operations, float(payload.get("fetched_at", 0))
141
+
142
+
143
+ def write_cache(base_url: str, operations: list[Operation]) -> None:
144
+ """写缓存。**写失败不算错误** —— 缓存是优化,不是依赖。"""
145
+ path = cache_path(base_url)
146
+ try:
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ path.write_text(
149
+ json.dumps(
150
+ {
151
+ "schema_version": CACHE_SCHEMA_VERSION,
152
+ "base_url": base_url,
153
+ "fetched_at": time.time(),
154
+ "operations": [op.to_payload() for op in operations],
155
+ },
156
+ ensure_ascii=False,
157
+ ),
158
+ encoding="utf-8",
159
+ )
160
+ except OSError:
161
+ pass
162
+
163
+
164
+ def fetch_operations(base_url: str, token: str, *, timeout: float = 10.0) -> list[Operation]:
165
+ """从网关拉操作清单。任何失败都抛 DiscoveryError。"""
166
+ url = f"{base_url.rstrip('/')}/operations"
167
+ try:
168
+ response = httpx.get(
169
+ url,
170
+ headers={"Authorization": f"Bearer {token}"},
171
+ timeout=timeout,
172
+ )
173
+ except httpx.HTTPError as exc:
174
+ raise DiscoveryError(f"无法连接网关 {url}:{exc}") from exc
175
+
176
+ if response.status_code == 401:
177
+ raise DiscoveryError(
178
+ "网关拒绝了令牌(401)。请检查 REDISCTL_TOKEN 是否有效、是否已过期。"
179
+ )
180
+ if response.status_code != 200:
181
+ raise DiscoveryError(f"网关返回 HTTP {response.status_code}:{response.text[:200]}")
182
+
183
+ try:
184
+ payload = response.json()
185
+ except ValueError as exc:
186
+ raise DiscoveryError(
187
+ f"网关返回了非 JSON 响应。请确认 REDISCTL_API_URL 指向网关的 /api 路径"
188
+ f"(当前为 {base_url})。"
189
+ ) from exc
190
+
191
+ if payload.get("schema_version") != CACHE_SCHEMA_VERSION:
192
+ raise DiscoveryError(
193
+ f"网关的操作清单格式版本为 {payload.get('schema_version')},"
194
+ f"本版本 redisctl 支持 {CACHE_SCHEMA_VERSION}。请升级 redisctl。"
195
+ )
196
+
197
+ try:
198
+ return [Operation.from_payload(o) for o in payload["operations"]]
199
+ except (KeyError, TypeError, ValueError) as exc:
200
+ raise DiscoveryError(f"操作清单的响应结构无法解析:{exc}") from exc
201
+
202
+
203
+ def discover(
204
+ base_url: str, token: str, *, force_refresh: bool = False
205
+ ) -> tuple[list[Operation], str | None]:
206
+ """发现操作清单。
207
+
208
+ 返回 (操作列表, 提示信息)。提示信息是给 stderr 的警告,正常时为 None。
209
+
210
+ 顺序:新鲜缓存 → 网络 → 陈旧缓存兜底。
211
+ """
212
+ cached = read_cache(base_url)
213
+ ttl = _ttl_seconds()
214
+ now = time.time()
215
+
216
+ if cached and not force_refresh:
217
+ operations, fetched_at = cached
218
+ if now - fetched_at < ttl:
219
+ return operations, None
220
+
221
+ try:
222
+ operations = fetch_operations(base_url, token)
223
+ except DiscoveryError as exc:
224
+ if cached:
225
+ operations, fetched_at = cached
226
+ age = int(now - fetched_at)
227
+ return operations, (
228
+ f"无法刷新操作清单({exc}),改用 {age} 秒前的缓存。"
229
+ "命令列表可能不是最新的。"
230
+ )
231
+ raise
232
+
233
+ write_cache(base_url, operations)
234
+ return operations, None
@@ -0,0 +1,47 @@
1
+ """`redisctl` 包入口点。
2
+
3
+ 顺序是固定的:**先读配置 → 再发现操作 → 最后构造命令行**。
4
+ 因为命令列表是从网关拉来的,不先发现就无法构造 typer 应用 ——
5
+ 这是「运行时发现」方案的固有代价,换来的是与网关零耦合、
6
+ 且永远不会出现「CLI 有这条命令、网关却没有」的错位。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ from .cli import fail, run, warn
14
+ from .client import gateway_token, gateway_url
15
+ from .discovery import DiscoveryError, discover
16
+
17
+
18
+ def main() -> None:
19
+ base_url = gateway_url()
20
+ token = gateway_token()
21
+
22
+ # 逃逸口:让用户在网关长时间不可达时显式选择「用旧缓存」而不是每次等超时
23
+ force_refresh = (os.getenv("REDISCTL_REFRESH") or "").strip().lower() in {
24
+ "1", "true", "yes", "on",
25
+ }
26
+
27
+ try:
28
+ operations, notice = discover(base_url, token, force_refresh=force_refresh)
29
+ except DiscoveryError as exc:
30
+ fail(
31
+ f"无法从网关获取操作清单:{exc}\n\n"
32
+ "redisctl 的命令列表由网关提供,因此这一步是必须的。\n"
33
+ "请检查:\n"
34
+ f" 1. REDISCTL_API_URL 是否指向网关的 /api 路径(当前 {base_url})\n"
35
+ " 2. REDISCTL_TOKEN 是否有效\n"
36
+ " 3. 网络是否可达网关",
37
+ )
38
+ return
39
+
40
+ if notice:
41
+ warn(notice)
42
+
43
+ run(operations)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()