tinet-clink2-cli 0.0.1__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.
cli_anything/README.md ADDED
@@ -0,0 +1,96 @@
1
+ Clink2 命令行客户端,封装 OpenAPI 接口,与 [智慧服务平台开发者中心](https://develop.clink.cn) 中参数与响应一致,适合脚本化集成与 Agent 调用。
2
+
3
+ **当前版本:** `0.0.1`
4
+ **PyPI:** https://pypi.org/project/tinet-clink2-cli/
5
+
6
+ ## 功能概览
7
+
8
+ | 子命令 | 说明 |
9
+ |--------|------|
10
+ | **`config`** | 本地凭证管理(AccessKeyId / AccessKeySecret / 接入地址 / Expires) |
11
+ | **`ivr`** | 语音导航查询:列表、节点列表 |
12
+
13
+ 所有出站 HTTP 请求自动附加:
14
+ - `X-Trace-Id: {UUID}`(每次请求新生成,不参与签名)
15
+
16
+ 本地默认写入 JSON Lines 日志:`~/.clink2/logs/clink2.log`(单文件最大 10MB,滚动保留 5 个)。
17
+
18
+ ## 环境要求
19
+
20
+ - Python **≥ 3.9**
21
+ - 运行时需访问公有云接入地址,例如 `https://api-bj.clink.cn` / `https://api-sh.clink.cn`
22
+ - 需在管理后台获取 AccessKeyId / AccessKeySecret(系统管理 → 安全设置 → 接口密钥)
23
+
24
+ ## 安装
25
+
26
+ 从 [PyPI](https://pypi.org/project/tinet-clink2-cli/) 安装(推荐):
27
+
28
+ ```bash
29
+ pip install tinet-clink2-cli
30
+ tinet-clink2-cli --version # 0.0.1
31
+ tinet-clink2-cli --help
32
+ ```
33
+
34
+ 指定版本或升级:
35
+
36
+ ```bash
37
+ pip install 'tinet-clink2-cli==0.0.1'
38
+ pip install --upgrade tinet-clink2-cli
39
+ ```
40
+
41
+ macOS 等受限环境可使用 [pipx](https://pipx.pypa.io/):
42
+
43
+ ```bash
44
+ pipx install tinet-clink2-cli
45
+ ```
46
+
47
+ ## 配置凭证
48
+
49
+ **配置文件:** `~/.clink2/config.json`
50
+
51
+ 入参名称与 OpenAPI 公共参数一致:
52
+
53
+ ```bash
54
+ tinet-clink2-cli config set \
55
+ --AccessKeyId your_access_key_id \
56
+ --AccessKeySecret your_access_key_secret \
57
+ --endpoint https://api-bj.clink.cn \
58
+ --Expires 60
59
+
60
+ tinet-clink2-cli config show
61
+ ```
62
+
63
+ 默认接入地址为北京:`https://api-bj.clink.cn`。`config show` 会对 AccessKeySecret 脱敏。
64
+
65
+ ## 快速示例
66
+
67
+ 顺序与上方「功能概览」一致。完整取值见各子命令 `--help` 或 [开发者中心](https://develop.clink.cn)。
68
+
69
+ ```bash
70
+ # config
71
+ tinet-clink2-cli config show
72
+
73
+ # ivr
74
+ tinet-clink2-cli ivr list
75
+ tinet-clink2-cli ivr nodes --ivrName your_ivr_name
76
+ tinet-clink2-cli --json ivr list
77
+ tinet-clink2-cli --json ivr nodes --ivrName your_ivr_name
78
+
79
+ # dry-run:仅校验入参与配置,不调用服务端,但仍写本地日志
80
+ tinet-clink2-cli --dry-run ivr list
81
+ tinet-clink2-cli --dry-run ivr nodes --ivrName your_ivr_name
82
+ ```
83
+
84
+ 全局 `--json` 输出 OpenAPI 原始 JSON。`--dry-run` 做本地校验与预览(不发请求),详见 `--help`。
85
+
86
+ ## 帮助与 API 文档
87
+
88
+ 安装后通过 `--help` 查看一级子命令:
89
+
90
+ ```bash
91
+ tinet-clink2-cli --help
92
+ tinet-clink2-cli config --help
93
+ tinet-clink2-cli ivr --help
94
+ ```
95
+
96
+ OpenAPI 契约与字段说明见 [智慧服务平台开发者中心](https://develop.clink.cn)。
@@ -0,0 +1,3 @@
1
+ """Clink2 OpenAPI CLI package."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,224 @@
1
+ """tinet-clink2-cli Click 入口"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import uuid
8
+ from typing import Any, Optional
9
+
10
+ import click
11
+
12
+ from cli_anything.clink2 import __version__
13
+ from cli_anything.clink2.core import config as cfg
14
+ from cli_anything.clink2.core.http import Clink2Client
15
+ from cli_anything.clink2.core.logging import write_log_record
16
+
17
+
18
+ @click.group()
19
+ @click.version_option(
20
+ version=__version__,
21
+ prog_name="tinet-clink2-cli",
22
+ message="%(prog)s,version %(version)s,released on 2026-07-20",
23
+ )
24
+ @click.option("--json", "as_json", is_flag=True, help="以 JSON 格式输出结果")
25
+ @click.option("--dry-run", is_flag=True, help="仅校验入参与配置,不调用服务端接口")
26
+ @click.pass_context
27
+ def cli(ctx: click.Context, as_json: bool, dry_run: bool) -> None:
28
+ """Clink2 OpenAPI 命令行工具"""
29
+ ctx.ensure_object(dict)
30
+ ctx.obj["as_json"] = as_json
31
+ ctx.obj["dry_run"] = dry_run
32
+
33
+
34
+ @cli.group()
35
+ def config() -> None:
36
+ """管理本地 AccessKey 与接入地址配置"""
37
+
38
+
39
+ @config.command("set")
40
+ @click.option(
41
+ "--AccessKeyId",
42
+ "access_key_id",
43
+ required=True,
44
+ help="访问密钥 ID",
45
+ )
46
+ @click.option(
47
+ "--AccessKeySecret",
48
+ "access_key_secret",
49
+ required=True,
50
+ help="访问密钥 Secret",
51
+ )
52
+ @click.option(
53
+ "--endpoint",
54
+ default=cfg.DEFAULT_ENDPOINT,
55
+ show_default=True,
56
+ help="API 接入地址(公有云北京/上海等)",
57
+ )
58
+ @click.option(
59
+ "--Expires",
60
+ "expires",
61
+ default=cfg.DEFAULT_EXPIRES,
62
+ show_default=True,
63
+ type=int,
64
+ help="签名有效时间,单位秒(与 OpenAPI 公共参数 Expires 一致,1–86400)",
65
+ )
66
+ def config_set(access_key_id: str, access_key_secret: str, endpoint: str, expires: int) -> None:
67
+ """将凭据保存到 ~/.clink2/config.json"""
68
+ path = cfg.save_config(
69
+ access_key_id=access_key_id,
70
+ access_key_secret=access_key_secret,
71
+ endpoint=endpoint,
72
+ expires=expires,
73
+ )
74
+ click.echo(f"配置已保存到 {path}")
75
+
76
+
77
+ @config.command("show")
78
+ def config_show() -> None:
79
+ """查看本地配置(AccessKeySecret 脱敏显示)"""
80
+ try:
81
+ data = cfg.load_config()
82
+ except FileNotFoundError as exc:
83
+ raise click.ClickException(str(exc)) from exc
84
+ display = {
85
+ "AccessKeyId": data["access_key_id"],
86
+ "AccessKeySecret": cfg.mask_secret(data["access_key_secret"]),
87
+ "endpoint": data.get("endpoint", cfg.DEFAULT_ENDPOINT),
88
+ "Expires": data.get("expires", cfg.DEFAULT_EXPIRES),
89
+ }
90
+ click.echo(json.dumps(display, ensure_ascii=False, indent=2))
91
+
92
+
93
+ def _load_or_fail() -> dict[str, Any]:
94
+ """加载配置;失败时抛出 ClickException"""
95
+ try:
96
+ return cfg.load_config()
97
+ except (FileNotFoundError, ValueError) as exc:
98
+ raise click.ClickException(str(exc)) from exc
99
+
100
+
101
+ def _emit(ctx: click.Context, payload: Any, *, exit_code: int = 0) -> None:
102
+ """输出结果(JSON 或文本)"""
103
+ if ctx.obj.get("as_json") or isinstance(payload, (dict, list)):
104
+ click.echo(json.dumps(payload, ensure_ascii=False, indent=2))
105
+ else:
106
+ click.echo(str(payload))
107
+ if exit_code:
108
+ ctx.exit(exit_code)
109
+
110
+
111
+ def _run_api(
112
+ ctx: click.Context,
113
+ command: str,
114
+ method: str,
115
+ path: str,
116
+ params: Optional[dict[str, Any]] = None,
117
+ ) -> None:
118
+ """执行 API 调用或 dry-run,并写本地日志"""
119
+ started = time.perf_counter()
120
+ conf = _load_or_fail()
121
+ endpoint = conf["endpoint"].rstrip("/")
122
+ full_url = f"{endpoint}{path}"
123
+ trace_id = str(uuid.uuid4())
124
+ dry_run = bool(ctx.obj.get("dry_run"))
125
+
126
+ if dry_run:
127
+ duration_ms = int((time.perf_counter() - started) * 1000)
128
+ write_log_record(
129
+ command=command,
130
+ duration_ms=duration_ms,
131
+ endpoint=full_url,
132
+ trace_id=trace_id,
133
+ http_status=None,
134
+ result="0",
135
+ error_type=None,
136
+ error_message=None,
137
+ dry_run=True,
138
+ request_sent=False,
139
+ )
140
+ payload = {
141
+ "dry_run": "ok",
142
+ "method": method,
143
+ "endpoint": full_url,
144
+ "params": params or {},
145
+ "trace_id": trace_id,
146
+ }
147
+ _emit(ctx, payload)
148
+ return
149
+
150
+ client = Clink2Client(
151
+ endpoint=endpoint,
152
+ access_key_id=conf["access_key_id"],
153
+ access_key_secret=conf["access_key_secret"],
154
+ expires=int(conf.get("expires", cfg.DEFAULT_EXPIRES)),
155
+ )
156
+ try:
157
+ body, meta = client.request(method, path, params=params or {}, trace_id=trace_id)
158
+ duration_ms = meta["duration_ms"]
159
+ status = meta["http_status"]
160
+ ok = 200 <= int(status) < 300
161
+ write_log_record(
162
+ command=command,
163
+ duration_ms=duration_ms,
164
+ endpoint=meta.get("url") or full_url,
165
+ trace_id=meta["trace_id"],
166
+ http_status=status,
167
+ result="0" if ok else str(status),
168
+ error_type=None if ok else "http_error",
169
+ error_message=None if ok else f"HTTP {status}",
170
+ )
171
+ if not ok:
172
+ err = {"error_type": "http_error", "error_message": f"HTTP {status}", "data": body}
173
+ _emit(ctx, err, exit_code=1)
174
+ return
175
+ _emit(ctx, body)
176
+ except click.exceptions.Exit:
177
+ raise
178
+ except Exception as exc: # noqa: BLE001
179
+ duration_ms = int((time.perf_counter() - started) * 1000)
180
+ write_log_record(
181
+ command=command,
182
+ duration_ms=duration_ms,
183
+ endpoint=full_url,
184
+ trace_id=trace_id,
185
+ http_status=None,
186
+ result="1",
187
+ error_type=type(exc).__name__,
188
+ error_message=str(exc),
189
+ )
190
+ err = {"error_type": type(exc).__name__, "error_message": str(exc)}
191
+ if ctx.obj.get("as_json"):
192
+ click.echo(json.dumps(err, ensure_ascii=False, indent=2), err=True)
193
+ else:
194
+ raise click.ClickException(str(exc)) from exc
195
+ ctx.exit(1)
196
+
197
+
198
+ @cli.group()
199
+ def ivr() -> None:
200
+ """语音导航(IVR)查询命令"""
201
+
202
+
203
+ @ivr.command("list")
204
+ @click.pass_context
205
+ def ivr_list(ctx: click.Context) -> None:
206
+ """查询语音导航列表"""
207
+ _run_api(ctx, "ivr list", "GET", "/cc/list_ivrs", params={})
208
+
209
+
210
+ @ivr.command("nodes")
211
+ @click.option(
212
+ "--ivrName",
213
+ "ivr_name",
214
+ required=True,
215
+ help="语音导航名称(与 OpenAPI 请求参数 ivrName 一致)",
216
+ )
217
+ @click.pass_context
218
+ def ivr_nodes(ctx: click.Context, ivr_name: str) -> None:
219
+ """查询语音导航节点列表"""
220
+ _run_api(ctx, "ivr nodes", "GET", "/cc/list_ivr_nodes", params={"ivrName": ivr_name})
221
+
222
+
223
+ if __name__ == "__main__":
224
+ cli()
@@ -0,0 +1 @@
1
+ """Core modules for config, auth, HTTP, and logging."""
@@ -0,0 +1,64 @@
1
+ """AK-SK signature helpers for Clink2 OpenAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ from typing import Mapping
9
+ from urllib.parse import quote
10
+
11
+
12
+ def build_string_to_sign(
13
+ method: str,
14
+ host: str,
15
+ path: str,
16
+ params: Mapping[str, str],
17
+ ) -> str:
18
+ """Build the canonical string to sign for a Clink2 request.
19
+
20
+ Args:
21
+ method: HTTP method (e.g. GET).
22
+ host: API host without scheme (e.g. api-bj.clink.cn).
23
+ path: Request path starting with /.
24
+ params: Query parameters excluding Signature.
25
+
26
+ Returns:
27
+ Canonical string used as hmac-sha1 input.
28
+ """
29
+ sorted_items = sorted(params.items(), key=lambda item: item[0])
30
+ encoded_pairs = [
31
+ f"{quote(str(name), safe='')}={quote(str(value), safe='')}"
32
+ for name, value in sorted_items
33
+ ]
34
+ query = "&".join(encoded_pairs)
35
+ return f"{method.upper()}{host}{path}?{query}"
36
+
37
+
38
+ def sign_request(
39
+ access_key_secret: str,
40
+ method: str,
41
+ host: str,
42
+ path: str,
43
+ params: Mapping[str, str],
44
+ ) -> str:
45
+ """Compute URL-encoded Signature for Clink2 OpenAPI.
46
+
47
+ Args:
48
+ access_key_secret: AccessKeySecret used as HMAC key.
49
+ method: HTTP method.
50
+ host: API host without scheme.
51
+ path: Request path.
52
+ params: Query parameters excluding Signature.
53
+
54
+ Returns:
55
+ URL-encoded Signature value for the query string.
56
+ """
57
+ string_to_sign = build_string_to_sign(method, host, path, params)
58
+ digest = hmac.new(
59
+ access_key_secret.encode("utf-8"),
60
+ string_to_sign.encode("utf-8"),
61
+ hashlib.sha1,
62
+ ).digest()
63
+ # Return raw base64; HTTP clients URL-encode query values once.
64
+ return base64.b64encode(digest).decode("utf-8")
@@ -0,0 +1,90 @@
1
+ """Local Clink2 CLI configuration stored under ~/.clink2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ DEFAULT_ENDPOINT = "https://api-bj.clink.cn"
11
+ DEFAULT_EXPIRES = 60
12
+
13
+
14
+ def config_dir() -> Path:
15
+ """Return the Clink2 config directory (~/.clink2)."""
16
+ return Path(os.path.expanduser("~")) / ".clink2"
17
+
18
+
19
+ def config_path() -> Path:
20
+ """Return the path to config.json."""
21
+ return config_dir() / "config.json"
22
+
23
+
24
+ def mask_secret(secret: str) -> str:
25
+ """Return a masked representation of an access key secret.
26
+
27
+ Args:
28
+ secret: Plaintext secret.
29
+
30
+ Returns:
31
+ Masked string that never equals the plaintext for non-empty secrets.
32
+ """
33
+ if not secret:
34
+ return ""
35
+ if len(secret) <= 4:
36
+ return "*" * len(secret)
37
+ return "*" * (len(secret) - 4) + secret[-4:]
38
+
39
+
40
+ def save_config(
41
+ access_key_id: str,
42
+ access_key_secret: str,
43
+ endpoint: str = DEFAULT_ENDPOINT,
44
+ expires: int = DEFAULT_EXPIRES,
45
+ ) -> Path:
46
+ """Persist credentials and endpoint to ~/.clink2/config.json.
47
+
48
+ Args:
49
+ access_key_id: AccessKeyId.
50
+ access_key_secret: AccessKeySecret.
51
+ endpoint: API base URL.
52
+ expires: Signature expires seconds.
53
+
54
+ Returns:
55
+ Path to the written config file.
56
+ """
57
+ directory = config_dir()
58
+ directory.mkdir(parents=True, exist_ok=True)
59
+ path = config_path()
60
+ payload = {
61
+ "access_key_id": access_key_id,
62
+ "access_key_secret": access_key_secret,
63
+ "endpoint": endpoint or DEFAULT_ENDPOINT,
64
+ "expires": int(expires) if expires is not None else DEFAULT_EXPIRES,
65
+ }
66
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
67
+ os.chmod(path, 0o600)
68
+ return path
69
+
70
+
71
+ def load_config() -> dict[str, Any]:
72
+ """Load configuration from ~/.clink2/config.json.
73
+
74
+ Returns:
75
+ Config dictionary.
76
+
77
+ Raises:
78
+ FileNotFoundError: If config file is missing.
79
+ ValueError: If required fields are missing.
80
+ """
81
+ path = config_path()
82
+ if not path.exists():
83
+ raise FileNotFoundError(f"未找到配置文件: {path}。请先执行 `tinet-clink2-cli config set`。")
84
+ data = json.loads(path.read_text(encoding="utf-8"))
85
+ for key in ("access_key_id", "access_key_secret"):
86
+ if not data.get(key):
87
+ raise ValueError(f"配置缺少必填字段: {key}")
88
+ data.setdefault("endpoint", DEFAULT_ENDPOINT)
89
+ data.setdefault("expires", DEFAULT_EXPIRES)
90
+ return data
@@ -0,0 +1,109 @@
1
+ """HTTPS client for Clink2 OpenAPI with AK-SK signing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import Any, Mapping, MutableMapping, Optional, Tuple
8
+ from urllib.parse import urlparse
9
+
10
+ import requests
11
+
12
+ from cli_anything.clink2.core.auth import sign_request
13
+
14
+
15
+ class Clink2Client:
16
+ """Clink2 OpenAPI HTTP client."""
17
+
18
+ def __init__(
19
+ self,
20
+ endpoint: str,
21
+ access_key_id: str,
22
+ access_key_secret: str,
23
+ expires: int = 60,
24
+ session: Optional[requests.Session] = None,
25
+ ) -> None:
26
+ """Initialize client credentials and base endpoint.
27
+
28
+ Args:
29
+ endpoint: Base URL such as https://api-bj.clink.cn.
30
+ access_key_id: AccessKeyId.
31
+ access_key_secret: AccessKeySecret.
32
+ expires: Signature validity in seconds.
33
+ session: Optional requests session.
34
+ """
35
+ self.endpoint = endpoint.rstrip("/")
36
+ self.access_key_id = access_key_id
37
+ self.access_key_secret = access_key_secret
38
+ self.expires = int(expires)
39
+ self.session = session or requests.Session()
40
+
41
+ def _host(self) -> str:
42
+ """Return API host parsed from endpoint."""
43
+ parsed = urlparse(self.endpoint)
44
+ if parsed.scheme != "https":
45
+ raise ValueError("Only HTTPS endpoints are supported")
46
+ if not parsed.netloc:
47
+ raise ValueError(f"Invalid endpoint: {self.endpoint}")
48
+ return parsed.netloc
49
+
50
+ def request(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ params: Optional[Mapping[str, Any]] = None,
55
+ trace_id: Optional[str] = None,
56
+ ) -> Tuple[Any, dict[str, Any]]:
57
+ """Send a signed HTTPS request.
58
+
59
+ Args:
60
+ method: HTTP method.
61
+ path: API path starting with /.
62
+ params: Business query parameters.
63
+ trace_id: Optional pre-generated trace id.
64
+
65
+ Returns:
66
+ Tuple of (JSON body, meta dict with http_status, trace_id, duration_ms, url).
67
+ """
68
+ business: MutableMapping[str, str] = {
69
+ str(k): str(v) for k, v in (params or {}).items() if v is not None
70
+ }
71
+ timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
72
+ query: MutableMapping[str, str] = {
73
+ **business,
74
+ "AccessKeyId": self.access_key_id,
75
+ "Expires": str(self.expires),
76
+ "Timestamp": timestamp,
77
+ }
78
+ host = self._host()
79
+ signature = sign_request(
80
+ access_key_secret=self.access_key_secret,
81
+ method=method,
82
+ host=host,
83
+ path=path,
84
+ params=query,
85
+ )
86
+ query["Signature"] = signature
87
+ tid = trace_id or str(uuid.uuid4())
88
+ url = f"{self.endpoint}{path}"
89
+ started = time.perf_counter()
90
+ response = self.session.request(
91
+ method=method.upper(),
92
+ url=url,
93
+ params=query,
94
+ headers={"X-Trace-Id": tid},
95
+ timeout=30,
96
+ verify=True,
97
+ )
98
+ duration_ms = int((time.perf_counter() - started) * 1000)
99
+ try:
100
+ body: Any = response.json()
101
+ except ValueError:
102
+ body = {"raw": response.text}
103
+ meta = {
104
+ "http_status": response.status_code,
105
+ "trace_id": tid,
106
+ "duration_ms": duration_ms,
107
+ "url": response.url,
108
+ }
109
+ return body, meta
@@ -0,0 +1,130 @@
1
+ """Local JSON-lines logging for tinet-clink2-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import platform
9
+ from datetime import datetime, timezone
10
+ from logging.handlers import RotatingFileHandler
11
+ from pathlib import Path
12
+ from typing import Any, Optional
13
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
14
+
15
+ from cli_anything.clink2 import __version__
16
+
17
+ _LOGGER_NAME = "tinet_clink2_cli"
18
+ _handler_configured = False
19
+
20
+
21
+ def logs_dir() -> Path:
22
+ """Return ~/.clink2/logs directory."""
23
+ return Path(os.path.expanduser("~")) / ".clink2" / "logs"
24
+
25
+
26
+ def get_log_path() -> Path:
27
+ """Return the primary log file path."""
28
+ return logs_dir() / "clink2.log"
29
+
30
+
31
+ def redact_endpoint(endpoint: str) -> str:
32
+ """Remove or redact Signature from an endpoint URL for safe logging.
33
+
34
+ Args:
35
+ endpoint: Full or partial request URL.
36
+
37
+ Returns:
38
+ URL safe to write to logs.
39
+ """
40
+ if not endpoint:
41
+ return endpoint
42
+ parts = urlsplit(endpoint)
43
+ if not parts.query:
44
+ return endpoint
45
+ filtered = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k != "Signature"]
46
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(filtered), parts.fragment))
47
+
48
+
49
+ def _ensure_handler() -> logging.Logger:
50
+ """Configure a rotating JSON-line file handler once."""
51
+ global _handler_configured
52
+ logger = logging.getLogger(_LOGGER_NAME)
53
+ logger.setLevel(logging.INFO)
54
+ logger.propagate = False
55
+ if not _handler_configured:
56
+ directory = logs_dir()
57
+ directory.mkdir(parents=True, exist_ok=True)
58
+ handler = RotatingFileHandler(
59
+ get_log_path(),
60
+ maxBytes=10 * 1024 * 1024,
61
+ backupCount=5,
62
+ encoding="utf-8",
63
+ )
64
+ handler.setFormatter(logging.Formatter("%(message)s"))
65
+ logger.addHandler(handler)
66
+ _handler_configured = True
67
+ return logger
68
+
69
+
70
+ def write_log_record(
71
+ command: str,
72
+ duration_ms: int,
73
+ endpoint: str,
74
+ trace_id: str,
75
+ http_status: Optional[int],
76
+ result: str,
77
+ error_type: Optional[str] = None,
78
+ error_message: Optional[str] = None,
79
+ dry_run: bool = False,
80
+ request_sent: Optional[bool] = None,
81
+ ) -> dict[str, Any]:
82
+ """Append one JSON log record to the rotating log file.
83
+
84
+ Args:
85
+ command: CLI command path (e.g. ivr list).
86
+ duration_ms: Elapsed milliseconds.
87
+ endpoint: Request URL (Signature will be redacted).
88
+ trace_id: Client-generated trace id.
89
+ http_status: HTTP status or None for dry-run.
90
+ result: Result code string ("0" for success).
91
+ error_type: Optional error type.
92
+ error_message: Optional error message.
93
+ dry_run: Whether this invocation was --dry-run.
94
+ request_sent: Whether an HTTP request was sent. Only written for dry-run.
95
+
96
+ Returns:
97
+ The record that was written.
98
+ """
99
+ now = datetime.now().astimezone()
100
+ record: dict[str, Any] = {
101
+ "timestamp": now.isoformat(timespec="milliseconds"),
102
+ "command": command,
103
+ "cli_version": __version__,
104
+ "os": platform.system(),
105
+ "arch": platform.machine(),
106
+ "duration_ms": duration_ms,
107
+ "endpoint": redact_endpoint(endpoint),
108
+ "trace_id": trace_id,
109
+ "http_status": http_status,
110
+ "result": result,
111
+ "error_type": error_type,
112
+ "error_message": error_message,
113
+ }
114
+ # Only emit dry-run markers for --dry-run invocations.
115
+ if dry_run:
116
+ record["dry_run"] = True
117
+ record["request_sent"] = False if request_sent is None else request_sent
118
+ logger = _ensure_handler()
119
+ logger.info(json.dumps(record, ensure_ascii=False))
120
+ return record
121
+
122
+
123
+ def reset_logging_for_tests() -> None:
124
+ """Reset logger handlers (test helper)."""
125
+ global _handler_configured
126
+ logger = logging.getLogger(_LOGGER_NAME)
127
+ for handler in list(logger.handlers):
128
+ handler.close()
129
+ logger.removeHandler(handler)
130
+ _handler_configured = False
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinet-clink2-cli
3
+ Version: 0.0.1
4
+ Summary: Clink2 OpenAPI CLI (AK-SK, IVR queries)
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: click>=8.0
8
+ Requires-Dist: requests>=2.28
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7.0; extra == "dev"
11
+ Requires-Dist: responses>=0.25; extra == "dev"
12
+ Requires-Dist: build>=1.0; extra == "dev"
13
+
14
+ Clink2 命令行客户端,封装 OpenAPI 接口,与 [智慧服务平台开发者中心](https://develop.clink.cn) 中参数与响应一致,适合脚本化集成与 Agent 调用。
15
+
16
+ **当前版本:** `0.0.1`
17
+ **PyPI:** https://pypi.org/project/tinet-clink2-cli/
18
+
19
+ ## 功能概览
20
+
21
+ | 子命令 | 说明 |
22
+ |--------|------|
23
+ | **`config`** | 本地凭证管理(AccessKeyId / AccessKeySecret / 接入地址 / Expires) |
24
+ | **`ivr`** | 语音导航查询:列表、节点列表 |
25
+
26
+ 所有出站 HTTP 请求自动附加:
27
+ - `X-Trace-Id: {UUID}`(每次请求新生成,不参与签名)
28
+
29
+ 本地默认写入 JSON Lines 日志:`~/.clink2/logs/clink2.log`(单文件最大 10MB,滚动保留 5 个)。
30
+
31
+ ## 环境要求
32
+
33
+ - Python **≥ 3.9**
34
+ - 运行时需访问公有云接入地址,例如 `https://api-bj.clink.cn` / `https://api-sh.clink.cn`
35
+ - 需在管理后台获取 AccessKeyId / AccessKeySecret(系统管理 → 安全设置 → 接口密钥)
36
+
37
+ ## 安装
38
+
39
+ 从 [PyPI](https://pypi.org/project/tinet-clink2-cli/) 安装(推荐):
40
+
41
+ ```bash
42
+ pip install tinet-clink2-cli
43
+ tinet-clink2-cli --version # 0.0.1
44
+ tinet-clink2-cli --help
45
+ ```
46
+
47
+ 指定版本或升级:
48
+
49
+ ```bash
50
+ pip install 'tinet-clink2-cli==0.0.1'
51
+ pip install --upgrade tinet-clink2-cli
52
+ ```
53
+
54
+ macOS 等受限环境可使用 [pipx](https://pipx.pypa.io/):
55
+
56
+ ```bash
57
+ pipx install tinet-clink2-cli
58
+ ```
59
+
60
+ ## 配置凭证
61
+
62
+ **配置文件:** `~/.clink2/config.json`
63
+
64
+ 入参名称与 OpenAPI 公共参数一致:
65
+
66
+ ```bash
67
+ tinet-clink2-cli config set \
68
+ --AccessKeyId your_access_key_id \
69
+ --AccessKeySecret your_access_key_secret \
70
+ --endpoint https://api-bj.clink.cn \
71
+ --Expires 60
72
+
73
+ tinet-clink2-cli config show
74
+ ```
75
+
76
+ 默认接入地址为北京:`https://api-bj.clink.cn`。`config show` 会对 AccessKeySecret 脱敏。
77
+
78
+ ## 快速示例
79
+
80
+ 顺序与上方「功能概览」一致。完整取值见各子命令 `--help` 或 [开发者中心](https://develop.clink.cn)。
81
+
82
+ ```bash
83
+ # config
84
+ tinet-clink2-cli config show
85
+
86
+ # ivr
87
+ tinet-clink2-cli ivr list
88
+ tinet-clink2-cli ivr nodes --ivrName your_ivr_name
89
+ tinet-clink2-cli --json ivr list
90
+ tinet-clink2-cli --json ivr nodes --ivrName your_ivr_name
91
+
92
+ # dry-run:仅校验入参与配置,不调用服务端,但仍写本地日志
93
+ tinet-clink2-cli --dry-run ivr list
94
+ tinet-clink2-cli --dry-run ivr nodes --ivrName your_ivr_name
95
+ ```
96
+
97
+ 全局 `--json` 输出 OpenAPI 原始 JSON。`--dry-run` 做本地校验与预览(不发请求),详见 `--help`。
98
+
99
+ ## 帮助与 API 文档
100
+
101
+ 安装后通过 `--help` 查看一级子命令:
102
+
103
+ ```bash
104
+ tinet-clink2-cli --help
105
+ tinet-clink2-cli config --help
106
+ tinet-clink2-cli ivr --help
107
+ ```
108
+
109
+ OpenAPI 契约与字段说明见 [智慧服务平台开发者中心](https://develop.clink.cn)。
@@ -0,0 +1,13 @@
1
+ cli_anything/README.md,sha256=1M5E-hTUR53yyND8CgeWMquq6Zjs4P93SjSqVFxYmH8,2755
2
+ cli_anything/clink2/__init__.py,sha256=2CNbLyESPhwI--uLtTNvB-oQe5NdXszzCxEg9_FRiUQ,57
3
+ cli_anything/clink2/clink2_cli.py,sha256=NsBf2ZC77K4IYBRHnRWpzqFbKA3PRMOzf4qEiqF1AgI,6711
4
+ cli_anything/clink2/core/__init__.py,sha256=CaPS_vDICcp6Dt5Cj38uYayFSfH_1-JmxN9_662KUpE,56
5
+ cli_anything/clink2/core/auth.py,sha256=Zk_onKLDJbnDU7Ai-uDMbW5nItEaS-KduThn97D5Ojw,1768
6
+ cli_anything/clink2/core/config.py,sha256=_doByMlGy0S6Tcyv0rVkdbv4YwOQMF4wPOGPt22YFQs,2551
7
+ cli_anything/clink2/core/http.py,sha256=ZlhzVsBaM5uYMnmHHXp1yEVqJrTCF7jC4l-eg5q0y-E,3445
8
+ cli_anything/clink2/core/logging.py,sha256=elB2EuEQZRr0_QY06FJ0gFfon71lHhEpIMuYz5I4_dM,4045
9
+ tinet_clink2_cli-0.0.1.dist-info/METADATA,sha256=qr1-hKJhgl4MyeN5L_0IPyf4HHRLghldjXfFJXdCcdo,3136
10
+ tinet_clink2_cli-0.0.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ tinet_clink2_cli-0.0.1.dist-info/entry_points.txt,sha256=yRgxlO1O0g3owUQPCI-dJbPUJioUhn9YVRV3pvn4vKY,72
12
+ tinet_clink2_cli-0.0.1.dist-info/top_level.txt,sha256=LI1GTe19xehXrxQtg-3ltETALXYkoctC4Y_iuDiCSRo,13
13
+ tinet_clink2_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tinet-clink2-cli = cli_anything.clink2.clink2_cli:cli
@@ -0,0 +1 @@
1
+ cli_anything