tinet-agent-cli 0.1.0.dev36__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 (104) hide show
  1. taco/__init__.py +1 -0
  2. taco/agent_openapi/__init__.py +1 -0
  3. taco/agent_openapi/catalog.py +198 -0
  4. taco/agent_openapi/models.py +47 -0
  5. taco/agent_openapi/parameters.py +111 -0
  6. taco/agent_openapi/service.py +73 -0
  7. taco/aikb/__init__.py +4 -0
  8. taco/aikb/catalog.py +380 -0
  9. taco/aikb/executors.py +157 -0
  10. taco/aikb/file_input.py +55 -0
  11. taco/aikb/models.py +42 -0
  12. taco/aikb/parameters.py +135 -0
  13. taco/aikb/service.py +68 -0
  14. taco/assets/__init__.py +1 -0
  15. taco/assets/agent_openapi/catalog.json +14 -0
  16. taco/assets/agent_openapi/operations/conversation/list-conversations.json +67 -0
  17. taco/assets/agent_openapi/operations/message/list-conversation-messages.json +80 -0
  18. taco/assets/agent_openapi/operations/trace/list-message-traces.json +72 -0
  19. taco/assets/aikb/catalog.json +41 -0
  20. taco/assets/aikb/operations/conversation/chat-conversation-on-open.json +99 -0
  21. taco/assets/aikb/operations/directory/delete-directory.json +81 -0
  22. taco/assets/aikb/operations/directory/edit-directory.json +97 -0
  23. taco/assets/aikb/operations/directory/list-directory-tree.json +106 -0
  24. taco/assets/aikb/operations/directory/save-directory.json +109 -0
  25. taco/assets/aikb/operations/faq/create-faq.json +193 -0
  26. taco/assets/aikb/operations/faq/delete-faq.json +81 -0
  27. taco/assets/aikb/operations/faq/describe-faq.json +82 -0
  28. taco/assets/aikb/operations/faq/edit-faq.json +193 -0
  29. taco/assets/aikb/operations/faq/list-faqs.json +136 -0
  30. taco/assets/aikb/operations/file/create-file.json +145 -0
  31. taco/assets/aikb/operations/file/delete-files.json +110 -0
  32. taco/assets/aikb/operations/file/describe-file.json +82 -0
  33. taco/assets/aikb/operations/file/get-file-upload-url.json +154 -0
  34. taco/assets/aikb/operations/file/list-files.json +146 -0
  35. taco/assets/aikb/operations/media/describe-faq-media-url.json +98 -0
  36. taco/assets/aikb/operations/media/describe-file-media-url.json +90 -0
  37. taco/assets/aikb/operations/oss/signature-for-upload.json +85 -0
  38. taco/assets/aikb/operations/recycle-bin/list-recycled-items.json +151 -0
  39. taco/assets/aikb/operations/repository/describe-bot-repository.json +71 -0
  40. taco/assets/aikb/operations/repository/list-private-repositories.json +75 -0
  41. taco/assets/aikb/operations/repository/list-public-repositories.json +75 -0
  42. taco/assets/aikb/operations/search/search-knowledge-on-open.json +180 -0
  43. taco/assets/examples/README.md +3 -0
  44. taco/assets/examples/agent-basic.json +16 -0
  45. taco/assets/examples/agent-builtin-tool.json +28 -0
  46. taco/assets/examples/agent-workflow-tool.json +30 -0
  47. taco/assets/examples/chatflow-basic.json +24 -0
  48. taco/assets/examples/workflow-http.json +34 -0
  49. taco/assets/manifest.json +12 -0
  50. taco/assets/scenarios/README.md +3 -0
  51. taco/assets/scenarios/agent-basic.json +80 -0
  52. taco/assets/scenarios/agent-builtin-tool.json +116 -0
  53. taco/assets/scenarios/agent-workflow-tool.json +277 -0
  54. taco/assets/schemas/README.md +3 -0
  55. taco/assets/schemas/agent-tool.json +71 -0
  56. taco/assets/schemas/agent.json +199 -0
  57. taco/assets/schemas/chatflow.json +1048 -0
  58. taco/assets/schemas/workflow.http.json +1052 -0
  59. taco/assets/schemas/workflow.json +1052 -0
  60. taco/assets/skills/README.md +3 -0
  61. taco/assets/skills/taco/SKILL.md +68 -0
  62. taco/assets/skills/taco/manifest.json +10 -0
  63. taco/cli.py +127 -0
  64. taco/commands/__init__.py +1 -0
  65. taco/commands/agent.py +760 -0
  66. taco/commands/aikb.py +132 -0
  67. taco/commands/app.py +151 -0
  68. taco/commands/category.py +37 -0
  69. taco/commands/chatflow.py +183 -0
  70. taco/commands/credential.py +222 -0
  71. taco/commands/discovery.py +409 -0
  72. taco/commands/model.py +55 -0
  73. taco/commands/profile.py +124 -0
  74. taco/commands/tool.py +61 -0
  75. taco/commands/workflow.py +714 -0
  76. taco/core/__init__.py +1 -0
  77. taco/core/access_token_manager.py +98 -0
  78. taco/core/api_client.py +263 -0
  79. taco/core/assets.py +81 -0
  80. taco/core/config_store.py +209 -0
  81. taco/core/context.py +19 -0
  82. taco/core/developer_center_client.py +165 -0
  83. taco/core/errors.py +19 -0
  84. taco/core/file_lock.py +80 -0
  85. taco/core/http_headers.py +21 -0
  86. taco/core/openapi_signer.py +221 -0
  87. taco/core/output.py +72 -0
  88. taco/core/profile_manager.py +217 -0
  89. taco/core/profile_resolver.py +151 -0
  90. taco/core/secure_file.py +76 -0
  91. taco/release.py +363 -0
  92. taco/services/__init__.py +1 -0
  93. taco/services/agent_service.py +314 -0
  94. taco/services/app_service.py +153 -0
  95. taco/services/category_service.py +57 -0
  96. taco/services/model_service.py +117 -0
  97. taco/services/tool_service.py +482 -0
  98. taco/services/workflow_compiler.py +1665 -0
  99. taco/services/workflow_service.py +652 -0
  100. taco/services/workflow_tool_service.py +439 -0
  101. tinet_agent_cli-0.1.0.dev36.dist-info/METADATA +158 -0
  102. tinet_agent_cli-0.1.0.dev36.dist-info/RECORD +104 -0
  103. tinet_agent_cli-0.1.0.dev36.dist-info/WHEEL +4 -0
  104. tinet_agent_cli-0.1.0.dev36.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from taco.core.errors import TacoError
10
+ from taco.core.http_headers import with_cli_user_agent
11
+ from taco.core.openapi_signer import OpenApiSigner
12
+
13
+
14
+ DEFAULT_TIMEOUT_SECONDS = 30.0
15
+ _TIME_ERROR_CODES = frozenset(
16
+ {"SignaturesExpired", "SignatureExpired", "RequestTimeTooSkewed"}
17
+ )
18
+
19
+
20
+ class DeveloperCenterClient:
21
+ def __init__(
22
+ self,
23
+ *,
24
+ endpoint: str,
25
+ access_key_id: str,
26
+ access_key_secret: str,
27
+ http_client: httpx.Client | None = None,
28
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
29
+ now_provider: Callable[[], datetime] | None = None,
30
+ ) -> None:
31
+ self.signer = OpenApiSigner(
32
+ endpoint=endpoint,
33
+ access_key_id=access_key_id,
34
+ access_key_secret=access_key_secret,
35
+ )
36
+ self.endpoint = self.signer.endpoint
37
+ self._owns_http_client = http_client is None
38
+ self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
39
+ self._now_provider = now_provider or (lambda: datetime.now(UTC))
40
+
41
+ def close(self) -> None:
42
+ if self._owns_http_client:
43
+ self.http_client.close()
44
+
45
+ def __enter__(self) -> DeveloperCenterClient:
46
+ return self
47
+
48
+ def __exit__(self, *args: object) -> None:
49
+ self.close()
50
+
51
+ def cli_auth(self) -> dict[str, Any]:
52
+ path = "/agent/v1/cli-auth"
53
+ payload = self.request_json("POST", path)
54
+ if not isinstance(payload, dict) or not isinstance(
55
+ payload.get("result"), dict
56
+ ):
57
+ raise _invalid_response_error(path, _request_id(payload))
58
+ result = payload["result"]
59
+ access_token = result.get("access_token")
60
+ if not isinstance(access_token, str) or not access_token.strip():
61
+ raise _invalid_response_error(path, _request_id(payload))
62
+ return result
63
+
64
+ def request_json(
65
+ self,
66
+ method: str,
67
+ path: str,
68
+ *,
69
+ query: Mapping[str, Any] | None = None,
70
+ json_body: Any = None,
71
+ ) -> Any:
72
+ signed = self.signer.sign(
73
+ method,
74
+ path,
75
+ query,
76
+ now=self._now_provider(),
77
+ )
78
+ try:
79
+ response = self.http_client.request(
80
+ method,
81
+ f"{self.endpoint}{path}",
82
+ params=signed.params,
83
+ json=json_body,
84
+ headers=with_cli_user_agent(),
85
+ )
86
+ except httpx.TimeoutException:
87
+ raise TacoError(
88
+ code="OPENAPI_TIMEOUT",
89
+ message="开发者中心请求超时。",
90
+ exit_code=4,
91
+ path=path,
92
+ hint="请检查网络连接后重试。",
93
+ ) from None
94
+ except httpx.HTTPError:
95
+ raise TacoError(
96
+ code="OPENAPI_CONNECTION_FAILED",
97
+ message="无法连接开发者中心。",
98
+ exit_code=4,
99
+ path=path,
100
+ hint="请检查网络、代理、证书和 endpoint。",
101
+ ) from None
102
+
103
+ if response.status_code >= 400:
104
+ raise _request_error(response, path)
105
+ try:
106
+ return response.json()
107
+ except ValueError:
108
+ raise _invalid_response_error(path) from None
109
+
110
+
111
+ def _request_error(response: httpx.Response, path: str) -> TacoError:
112
+ try:
113
+ payload = response.json()
114
+ except ValueError:
115
+ payload = None
116
+ remote_code, request_id = _safe_error_fields(payload)
117
+ hint = "请检查 AccessKey 权限、IP 白名单和请求参数。"
118
+ if remote_code in _TIME_ERROR_CODES:
119
+ hint = "请检查本机 UTC 时间并启用 NTP 时间同步后重试。"
120
+ return TacoError(
121
+ code="OPENAPI_REQUEST_FAILED",
122
+ message=f"开发者中心请求失败,HTTP 状态码 {response.status_code}。",
123
+ exit_code=4,
124
+ status=response.status_code,
125
+ hint=hint,
126
+ remote_code=remote_code,
127
+ request_id=request_id,
128
+ path=path,
129
+ )
130
+
131
+
132
+ def _safe_error_fields(payload: Any) -> tuple[str | None, str | None]:
133
+ if not isinstance(payload, dict):
134
+ return None, None
135
+ request_id = _request_id(payload)
136
+ detail = payload.get("error")
137
+ if not isinstance(detail, dict):
138
+ detail = payload
139
+ remote_code = detail.get("code")
140
+ if not isinstance(remote_code, str) or not remote_code.strip():
141
+ remote_code = None
142
+ return remote_code, request_id
143
+
144
+
145
+ def _request_id(payload: Any) -> str | None:
146
+ if not isinstance(payload, dict):
147
+ return None
148
+ request_id = payload.get("requestId")
149
+ if isinstance(request_id, str) and request_id.strip():
150
+ return request_id
151
+ return None
152
+
153
+
154
+ def _invalid_response_error(
155
+ path: str,
156
+ request_id: str | None = None,
157
+ ) -> TacoError:
158
+ return TacoError(
159
+ code="OPENAPI_RESPONSE_INVALID",
160
+ message="开发者中心成功响应不是合法的 JSON 业务对象。",
161
+ exit_code=4,
162
+ hint="请根据 requestId 检查网关和服务端响应。",
163
+ request_id=request_id,
164
+ path=path,
165
+ )
taco/core/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class TacoError(Exception):
8
+ code: str
9
+ message: str
10
+ exit_code: int = 1
11
+ status: int | None = None
12
+ hint: str | None = None
13
+ remote_code: str | None = None
14
+ app_id: str | None = None
15
+ request_id: str | None = None
16
+ path: str | None = None
17
+
18
+ def __post_init__(self) -> None:
19
+ Exception.__init__(self, self.message)
taco/core/file_lock.py ADDED
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from filelock import FileLock, Timeout
6
+
7
+ from taco.core.errors import TacoError
8
+
9
+
10
+ DEFAULT_LOCK_TIMEOUT_SECONDS = 10.0
11
+ LOCK_POLL_INTERVAL_SECONDS = 0.05
12
+
13
+
14
+ class InterProcessFileLock:
15
+ """Cross-platform advisory file lock with bounded waiting."""
16
+
17
+ def __init__(
18
+ self,
19
+ path: Path,
20
+ *,
21
+ timeout_seconds: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
22
+ ) -> None:
23
+ self.path = path
24
+ self.timeout_seconds = max(0.0, timeout_seconds)
25
+ self._lock = FileLock(
26
+ path,
27
+ timeout=self.timeout_seconds,
28
+ mode=0o600,
29
+ )
30
+ self._acquired = False
31
+
32
+ def acquire(self) -> None:
33
+ if self._acquired:
34
+ return
35
+ try:
36
+ self._lock.acquire(
37
+ timeout=self.timeout_seconds,
38
+ poll_interval=LOCK_POLL_INTERVAL_SECONDS,
39
+ )
40
+ self._acquired = True
41
+ try:
42
+ self.path.chmod(0o600)
43
+ except OSError:
44
+ pass
45
+ except Timeout:
46
+ raise TacoError(
47
+ code="CONFIG_FILE_BUSY",
48
+ message="TACO 配置文件正被其他进程修改。",
49
+ exit_code=2,
50
+ hint="请稍后重试。",
51
+ path=str(self.path),
52
+ ) from None
53
+ except OSError as error:
54
+ raise _lock_file_error(self.path) from error
55
+
56
+ def release(self) -> None:
57
+ if not self._acquired:
58
+ return
59
+ self._acquired = False
60
+ try:
61
+ self._lock.release()
62
+ except OSError as error:
63
+ raise _lock_file_error(self.path) from error
64
+
65
+ def __enter__(self) -> InterProcessFileLock:
66
+ self.acquire()
67
+ return self
68
+
69
+ def __exit__(self, *args: object) -> None:
70
+ self.release()
71
+
72
+
73
+ def _lock_file_error(path: Path) -> TacoError:
74
+ return TacoError(
75
+ code="CONFIG_FILE_WRITE_FAILED",
76
+ message="无法创建或锁定 TACO 配置锁文件。",
77
+ exit_code=2,
78
+ hint=f"请检查目录权限和文件类型:{path.parent}",
79
+ path=str(path),
80
+ )
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from taco import __version__
6
+
7
+
8
+ def cli_user_agent() -> str:
9
+ return f"CLI/TACO/{__version__}"
10
+
11
+
12
+ def with_cli_user_agent(
13
+ headers: Mapping[str, str] | None = None,
14
+ ) -> dict[str, str]:
15
+ merged = {
16
+ key: value
17
+ for key, value in (headers or {}).items()
18
+ if key.lower() != "user-agent"
19
+ }
20
+ merged["User-Agent"] = cli_user_agent()
21
+ return merged
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import ipaddress
7
+ import re
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from typing import Any
12
+ from urllib.parse import quote, urlsplit
13
+
14
+ from taco.core.errors import TacoError
15
+
16
+
17
+ DEFAULT_EXPIRES_SECONDS = 60
18
+ MIN_EXPIRES_SECONDS = 1
19
+ MAX_EXPIRES_SECONDS = 86400
20
+ RESERVED_QUERY_NAMES = frozenset(
21
+ {"AccessKeyId", "Expires", "Timestamp", "Signature"}
22
+ )
23
+ ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH"})
24
+ _DOMAIN_PATTERN = re.compile(r"^[A-Za-z0-9.-]+$")
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class SignedRequest:
29
+ params: tuple[tuple[str, str], ...] = field(repr=False)
30
+ signature: str = field(repr=False)
31
+ canonical_string: str = field(repr=False)
32
+
33
+
34
+ class OpenApiSigner:
35
+ def __init__(
36
+ self,
37
+ *,
38
+ endpoint: str,
39
+ access_key_id: str,
40
+ access_key_secret: str,
41
+ expires: int = DEFAULT_EXPIRES_SECONDS,
42
+ ) -> None:
43
+ self.endpoint, self.authority = normalize_endpoint(endpoint)
44
+ if not access_key_id.strip() or not access_key_secret.strip():
45
+ raise TacoError(
46
+ code="OPENAPI_CREDENTIAL_REQUIRED",
47
+ message="AccessKeyId 和 AccessKeySecret 不能为空。",
48
+ exit_code=2,
49
+ )
50
+ if not MIN_EXPIRES_SECONDS <= expires <= MAX_EXPIRES_SECONDS:
51
+ raise TacoError(
52
+ code="OPENAPI_EXPIRES_INVALID",
53
+ message="签名有效时间必须在 1 到 86400 秒之间。",
54
+ exit_code=2,
55
+ )
56
+ self._access_key_id = access_key_id
57
+ self._access_key_secret = access_key_secret
58
+ self._expires = expires
59
+
60
+ def sign(
61
+ self,
62
+ method: str,
63
+ path: str,
64
+ query: Mapping[str, Any] | None = None,
65
+ *,
66
+ now: datetime | None = None,
67
+ ) -> SignedRequest:
68
+ normalized_method = method.upper()
69
+ if normalized_method not in ALLOWED_METHODS:
70
+ raise TacoError(
71
+ code="OPENAPI_METHOD_INVALID",
72
+ message="开发者中心请求方法不受支持。",
73
+ exit_code=2,
74
+ path=_safe_path(path),
75
+ )
76
+ _validate_path(path)
77
+ timestamp = _format_timestamp(now or datetime.now(UTC))
78
+ business_params = _expand_query(query or {})
79
+ common_params = [
80
+ ("AccessKeyId", self._access_key_id),
81
+ ("Expires", str(self._expires)),
82
+ ("Timestamp", timestamp),
83
+ ]
84
+ unsigned_params = tuple(sorted((*business_params, *common_params)))
85
+ encoded_query = "&".join(
86
+ f"{_encode(name)}={_encode(value)}" for name, value in unsigned_params
87
+ )
88
+ canonical_string = (
89
+ f"{normalized_method}{self.authority}{path}?{encoded_query}"
90
+ )
91
+ digest = hmac.new(
92
+ self._access_key_secret.encode("utf-8"),
93
+ canonical_string.encode("utf-8"),
94
+ hashlib.sha1,
95
+ ).digest()
96
+ signature = base64.b64encode(digest).decode("ascii")
97
+ return SignedRequest(
98
+ params=(*unsigned_params, ("Signature", signature)),
99
+ signature=signature,
100
+ canonical_string=canonical_string,
101
+ )
102
+
103
+
104
+ def normalize_endpoint(endpoint: str) -> tuple[str, str]:
105
+ try:
106
+ parsed = urlsplit(endpoint)
107
+ port = parsed.port
108
+ except (TypeError, ValueError) as error:
109
+ raise _endpoint_error() from error
110
+ if (
111
+ parsed.scheme.lower() != "https"
112
+ or not parsed.hostname
113
+ or parsed.username is not None
114
+ or parsed.password is not None
115
+ or parsed.path not in ("", "/")
116
+ or parsed.query
117
+ or parsed.fragment
118
+ ):
119
+ raise _endpoint_error()
120
+
121
+ host = parsed.hostname.lower()
122
+ if any(character.isspace() for character in host):
123
+ raise _endpoint_error()
124
+ try:
125
+ ipaddress.ip_address(host)
126
+ is_ipv6 = ":" in host
127
+ except ValueError:
128
+ if not _DOMAIN_PATTERN.fullmatch(host):
129
+ raise _endpoint_error()
130
+ is_ipv6 = False
131
+
132
+ authority = f"[{host}]" if is_ipv6 else host
133
+ if port is not None and port != 443:
134
+ authority = f"{authority}:{port}"
135
+ return f"https://{authority}", authority
136
+
137
+
138
+ def _expand_query(query: Mapping[str, Any]) -> list[tuple[str, str]]:
139
+ params: list[tuple[str, str]] = []
140
+ for name, value in query.items():
141
+ if not isinstance(name, str) or not name:
142
+ raise TacoError(
143
+ code="OPENAPI_QUERY_INVALID",
144
+ message="开发者中心 Query 参数名必须是非空字符串。",
145
+ exit_code=2,
146
+ )
147
+ if name in RESERVED_QUERY_NAMES:
148
+ raise TacoError(
149
+ code="OPENAPI_QUERY_RESERVED",
150
+ message="业务参数不能覆盖开发者中心公共鉴权参数。",
151
+ exit_code=2,
152
+ )
153
+ if value is None:
154
+ continue
155
+ if isinstance(value, Sequence) and not isinstance(
156
+ value, (str, bytes, bytearray)
157
+ ):
158
+ for index, item in enumerate(value):
159
+ params.append((f"{name}[{index}]", _query_value(item)))
160
+ else:
161
+ params.append((name, _query_value(value)))
162
+ return params
163
+
164
+
165
+ def _query_value(value: Any) -> str:
166
+ if isinstance(value, bool):
167
+ return "true" if value else "false"
168
+ if isinstance(value, (str, int, float)):
169
+ return str(value)
170
+ raise TacoError(
171
+ code="OPENAPI_QUERY_INVALID",
172
+ message="开发者中心 Query 参数值类型不受支持。",
173
+ exit_code=2,
174
+ )
175
+
176
+
177
+ def _format_timestamp(value: datetime) -> str:
178
+ if value.tzinfo is None or value.utcoffset() is None:
179
+ raise TacoError(
180
+ code="OPENAPI_TIMESTAMP_INVALID",
181
+ message="签名时间必须包含时区。",
182
+ exit_code=2,
183
+ )
184
+ return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
185
+
186
+
187
+ def _validate_path(path: str) -> None:
188
+ parsed = urlsplit(path)
189
+ if (
190
+ not path.startswith("/")
191
+ or path.startswith("//")
192
+ or parsed.scheme
193
+ or parsed.netloc
194
+ or parsed.query
195
+ or parsed.fragment
196
+ ):
197
+ raise TacoError(
198
+ code="OPENAPI_PATH_INVALID",
199
+ message="开发者中心 API path 必须是绝对路径且不能包含 Query 或 fragment。",
200
+ exit_code=2,
201
+ path=_safe_path(path),
202
+ )
203
+
204
+
205
+ def _safe_path(path: object) -> str | None:
206
+ if not isinstance(path, str):
207
+ return None
208
+ return path.split("?", 1)[0].split("#", 1)[0]
209
+
210
+
211
+ def _encode(value: str) -> str:
212
+ return quote(value, safe="-_.~", encoding="utf-8", errors="strict")
213
+
214
+
215
+ def _endpoint_error() -> TacoError:
216
+ return TacoError(
217
+ code="OPENAPI_ENDPOINT_INVALID",
218
+ message="开发者中心 endpoint 必须是 HTTPS 平台根地址。",
219
+ exit_code=2,
220
+ hint="只允许 scheme、host 和可选端口,不能包含路径、用户信息、Query 或 fragment。",
221
+ )
taco/core/output.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import typer
7
+
8
+ from taco.core.context import get_cli_context
9
+ from taco.core.errors import TacoError
10
+
11
+
12
+ def mask_token(token: str | None) -> str:
13
+ if not token:
14
+ return ""
15
+ if len(token) <= 8:
16
+ return "***"
17
+ return f"{token[:4]}...{token[-4:]}"
18
+
19
+
20
+ def success(data: Any) -> dict[str, Any]:
21
+ return {"ok": True, "data": data}
22
+
23
+
24
+ def failure(error: TacoError) -> dict[str, Any]:
25
+ payload: dict[str, Any] = {
26
+ "code": error.code,
27
+ "message": error.message,
28
+ "exit_code": error.exit_code,
29
+ }
30
+ if error.status is not None:
31
+ payload["status"] = error.status
32
+ if error.hint is not None:
33
+ payload["hint"] = error.hint
34
+ if error.app_id is not None:
35
+ payload["app_id"] = error.app_id
36
+ if error.remote_code is not None:
37
+ payload["remote_code"] = error.remote_code
38
+ if error.request_id is not None:
39
+ payload["request_id"] = error.request_id
40
+ if error.path is not None:
41
+ payload["path"] = error.path
42
+ return {"ok": False, "error": payload}
43
+
44
+
45
+ def to_json_line(payload: dict[str, Any]) -> str:
46
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
47
+
48
+
49
+ def emit_success(
50
+ ctx: typer.Context,
51
+ data: Any,
52
+ *,
53
+ human_message: str | None = None,
54
+ json_default: bool = False,
55
+ ) -> None:
56
+ if get_cli_context(ctx).json_output or json_default or human_message is None:
57
+ typer.echo(to_json_line(success(data)))
58
+ else:
59
+ typer.echo(human_message)
60
+
61
+
62
+ def exit_with_error(
63
+ ctx: typer.Context,
64
+ error: TacoError,
65
+ *,
66
+ json_default: bool = False,
67
+ ) -> None:
68
+ if get_cli_context(ctx).json_output or json_default:
69
+ typer.echo(to_json_line(failure(error)), err=True)
70
+ else:
71
+ typer.echo(f"{error.code}: {error.message}", err=True)
72
+ raise typer.Exit(error.exit_code)