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,98 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import threading
5
+ from datetime import UTC, datetime
6
+
7
+ import httpx
8
+
9
+ from taco.core.config_store import ConfigStore, TacoConfig
10
+ from taco.core.developer_center_client import DeveloperCenterClient
11
+ from taco.core.profile_resolver import ProfileResolver, ResolvedProfile
12
+
13
+
14
+ _REFRESH_LOCKS: dict[tuple[str, str], threading.Lock] = {}
15
+ _REFRESH_LOCKS_GUARD = threading.Lock()
16
+
17
+
18
+ def credential_fingerprint(profile: ResolvedProfile) -> str:
19
+ material = f"{profile.endpoint.rstrip('/')}\n{profile.access_key_id}"
20
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
21
+
22
+
23
+ class AccessTokenManager:
24
+ def __init__(
25
+ self,
26
+ store: ConfigStore | None = None,
27
+ *,
28
+ resolver: ProfileResolver | None = None,
29
+ http_client: httpx.Client | None = None,
30
+ ) -> None:
31
+ self.store = store or ConfigStore()
32
+ self.resolver = resolver or ProfileResolver(self.store)
33
+ self.http_client = http_client
34
+
35
+ def get_access_token(self, profile: ResolvedProfile) -> str:
36
+ cached = self._matching_token(profile)
37
+ if cached is not None:
38
+ return cached
39
+ if profile.legacy_access_token:
40
+ self._save(profile, profile.legacy_access_token)
41
+ return profile.legacy_access_token
42
+ return self.refresh_access_token(profile)
43
+
44
+ def refresh_access_token(
45
+ self,
46
+ profile: ResolvedProfile,
47
+ stale_access_token: str | None = None,
48
+ ) -> str:
49
+ with _profile_refresh_lock(self.store, profile.name):
50
+ cached = self._matching_token(profile)
51
+ if (
52
+ stale_access_token is not None
53
+ and cached is not None
54
+ and cached != stale_access_token
55
+ ):
56
+ return cached
57
+ client = DeveloperCenterClient(
58
+ endpoint=profile.endpoint,
59
+ access_key_id=profile.access_key_id,
60
+ access_key_secret=profile.access_key_secret,
61
+ http_client=self.http_client,
62
+ )
63
+ try:
64
+ token = str(client.cli_auth()["access_token"])
65
+ finally:
66
+ client.close()
67
+ self._save(profile, token)
68
+ return token
69
+
70
+ def _matching_token(self, profile: ResolvedProfile) -> str | None:
71
+ cached = self.store.get_auth_token(profile.name)
72
+ if not cached or cached.get("credential_fingerprint") != credential_fingerprint(profile):
73
+ return None
74
+ token = cached.get("access_token")
75
+ return token if isinstance(token, str) and token else None
76
+
77
+ def _save(self, profile: ResolvedProfile, token: str) -> None:
78
+ known_names = self.resolver.known_names()
79
+
80
+ def mutate(config: TacoConfig) -> None:
81
+ config.auth_tokens = {
82
+ name: value
83
+ for name, value in config.auth_tokens.items()
84
+ if name in known_names
85
+ }
86
+ config.auth_tokens[profile.name] = {
87
+ "access_token": token,
88
+ "credential_fingerprint": credential_fingerprint(profile),
89
+ "updated_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
90
+ }
91
+
92
+ self.store.update(mutate)
93
+
94
+
95
+ def _profile_refresh_lock(store: ConfigStore, profile_name: str) -> threading.Lock:
96
+ key = (str(store.path.resolve()), profile_name)
97
+ with _REFRESH_LOCKS_GUARD:
98
+ return _REFRESH_LOCKS.setdefault(key, threading.Lock())
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Iterator
6
+
7
+ import httpx
8
+
9
+ from taco.core.access_token_manager import AccessTokenManager
10
+ from taco.core.config_store import ConfigStore
11
+ from taco.core.errors import TacoError
12
+ from taco.core.http_headers import with_cli_user_agent
13
+ from taco.core.profile_resolver import ProfileResolver, ResolvedProfile
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class SseEvent:
18
+ data: Any
19
+ event: str | None = None
20
+ id: str | None = None
21
+ retry: int | None = None
22
+
23
+
24
+ class ApiClient:
25
+ def __init__(
26
+ self,
27
+ store: ConfigStore | None = None,
28
+ *,
29
+ profile_resolver: ProfileResolver | None = None,
30
+ token_manager: AccessTokenManager | None = None,
31
+ http_client: httpx.Client | None = None,
32
+ ) -> None:
33
+ self.store = store or ConfigStore()
34
+ self._owns_http_client = http_client is None
35
+ self.http_client = http_client or httpx.Client(timeout=30)
36
+ self.profile_resolver = profile_resolver or ProfileResolver(self.store)
37
+ self.token_manager = token_manager or AccessTokenManager(
38
+ self.store,
39
+ resolver=self.profile_resolver,
40
+ http_client=self.http_client,
41
+ )
42
+
43
+ def close(self) -> None:
44
+ if self._owns_http_client:
45
+ self.http_client.close()
46
+
47
+ def __enter__(self) -> ApiClient:
48
+ return self
49
+
50
+ def __exit__(self, *args: object) -> None:
51
+ self.close()
52
+
53
+ def get(
54
+ self,
55
+ path: str,
56
+ *,
57
+ params: dict[str, Any] | None = None,
58
+ profile: str | None = None,
59
+ ) -> Any:
60
+ return self.request("GET", path, params=params, profile=profile)
61
+
62
+ def post(
63
+ self,
64
+ path: str,
65
+ *,
66
+ json: dict[str, Any] | None = None,
67
+ profile: str | None = None,
68
+ ) -> Any:
69
+ return self.request("POST", path, json=json, profile=profile)
70
+
71
+ def request(
72
+ self,
73
+ method: str,
74
+ path: str,
75
+ *,
76
+ params: dict[str, Any] | None = None,
77
+ json: dict[str, Any] | None = None,
78
+ profile: str | None = None,
79
+ retry_on_auth: bool = True,
80
+ ) -> Any:
81
+ resolved = self.profile_resolver.resolve(profile)
82
+ access_token = self.token_manager.get_access_token(resolved)
83
+ try:
84
+ response = self.http_client.request(
85
+ method,
86
+ _console_url(resolved, path),
87
+ params=params,
88
+ json=json,
89
+ headers=self._headers(access_token),
90
+ )
91
+ except httpx.HTTPError as error:
92
+ raise _connection_error(error) from error
93
+
94
+ if response.status_code == 401 and retry_on_auth:
95
+ self.token_manager.refresh_access_token(
96
+ resolved,
97
+ stale_access_token=access_token,
98
+ )
99
+ return self.request(
100
+ method,
101
+ path,
102
+ params=params,
103
+ json=json,
104
+ profile=resolved.name,
105
+ retry_on_auth=False,
106
+ )
107
+
108
+ if response.status_code >= 400:
109
+ raise _request_error(response)
110
+
111
+ if not response.content:
112
+ return None
113
+ try:
114
+ return response.json()
115
+ except ValueError as error:
116
+ raise TacoError(
117
+ code="API_RESPONSE_INVALID",
118
+ message="Console API 成功响应不是合法 JSON。",
119
+ exit_code=4,
120
+ status=response.status_code,
121
+ hint="请检查网关、代理和服务端响应格式。",
122
+ ) from error
123
+
124
+ def stream(
125
+ self,
126
+ method: str,
127
+ path: str,
128
+ *,
129
+ params: dict[str, Any] | None = None,
130
+ json: dict[str, Any] | None = None,
131
+ profile: str | None = None,
132
+ retry_on_auth: bool = True,
133
+ ) -> Iterator[SseEvent]:
134
+ resolved = self.profile_resolver.resolve(profile)
135
+ access_token = self.token_manager.get_access_token(resolved)
136
+ headers = self._headers(access_token)
137
+ headers["Accept"] = "text/event-stream"
138
+ should_retry = False
139
+
140
+ try:
141
+ with self.http_client.stream(
142
+ method,
143
+ _console_url(resolved, path),
144
+ params=params,
145
+ json=json,
146
+ headers=headers,
147
+ ) as response:
148
+ if response.status_code == 401 and retry_on_auth:
149
+ should_retry = True
150
+ else:
151
+ if response.status_code >= 400:
152
+ response.read()
153
+ raise _request_error(response)
154
+ yield from _iter_sse_events(response.iter_lines())
155
+ return
156
+ except httpx.HTTPError as error:
157
+ raise _connection_error(error) from error
158
+
159
+ if should_retry:
160
+ self.token_manager.refresh_access_token(
161
+ resolved,
162
+ stale_access_token=access_token,
163
+ )
164
+ yield from self.stream(
165
+ method,
166
+ path,
167
+ params=params,
168
+ json=json,
169
+ profile=resolved.name,
170
+ retry_on_auth=False,
171
+ )
172
+
173
+ def _headers(self, access_token: str) -> dict[str, str]:
174
+ return with_cli_user_agent({"Authorization": f"Bearer {access_token}"})
175
+
176
+
177
+ def _console_url(profile: ResolvedProfile, path: str) -> str:
178
+ return f"{profile.endpoint}/agent/console/api/{path.lstrip('/')}"
179
+
180
+
181
+ def _request_error(response: httpx.Response) -> TacoError:
182
+ try:
183
+ payload = response.json()
184
+ except ValueError:
185
+ payload = {}
186
+ message = None
187
+ remote_code = None
188
+ if isinstance(payload, dict):
189
+ if payload.get("message"):
190
+ message = str(payload["message"])
191
+ if isinstance(payload.get("code"), str):
192
+ remote_code = payload["code"]
193
+ return TacoError(
194
+ code="API_REQUEST_FAILED",
195
+ message=message
196
+ or f"Console API 请求失败,HTTP 状态码 {response.status_code}。",
197
+ exit_code=4,
198
+ status=response.status_code,
199
+ hint="请检查 Console API 地址、认证状态和请求参数。",
200
+ remote_code=remote_code,
201
+ )
202
+
203
+
204
+ def _connection_error(error: httpx.HTTPError) -> TacoError:
205
+ return TacoError(
206
+ code="API_CONNECTION_FAILED",
207
+ message=f"无法连接 Console API:{error}",
208
+ exit_code=4,
209
+ hint="请检查网络、代理、证书和 Console API 地址。",
210
+ )
211
+
212
+
213
+ def _iter_sse_events(lines: Iterator[str]) -> Iterator[SseEvent]:
214
+ data_lines: list[str] = []
215
+ event_type: str | None = None
216
+ event_id: str | None = None
217
+ retry: int | None = None
218
+ first_line = True
219
+
220
+ def dispatch() -> SseEvent | None:
221
+ if not data_lines:
222
+ return None
223
+ raw_data = "\n".join(data_lines)
224
+ if raw_data == "[DONE]":
225
+ raise StopIteration
226
+ try:
227
+ data: Any = json.loads(raw_data)
228
+ except json.JSONDecodeError:
229
+ data = raw_data
230
+ return SseEvent(data=data, event=event_type, id=event_id, retry=retry)
231
+
232
+ try:
233
+ for line in lines:
234
+ if first_line:
235
+ line = line.removeprefix("\ufeff")
236
+ first_line = False
237
+ if line == "":
238
+ event = dispatch()
239
+ if event is not None:
240
+ yield event
241
+ data_lines = []
242
+ event_type = None
243
+ continue
244
+
245
+ if line.startswith(":"):
246
+ continue
247
+ field, separator, value = line.partition(":")
248
+ if separator and value.startswith(" "):
249
+ value = value[1:]
250
+ if field == "data":
251
+ data_lines.append(value)
252
+ elif field == "event":
253
+ event_type = value
254
+ elif field == "id" and "\x00" not in value:
255
+ event_id = value
256
+ elif field == "retry" and value.isdigit():
257
+ retry = int(value)
258
+
259
+ event = dispatch()
260
+ if event is not None:
261
+ yield event
262
+ except StopIteration:
263
+ return
taco/core/assets.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from importlib import resources
6
+ from typing import Any
7
+
8
+ from taco.core.errors import TacoError
9
+
10
+
11
+ _RESOURCE_NAME = re.compile(r"^[a-z0-9][a-z0-9.-]*$")
12
+
13
+
14
+ def load_json_asset(group: str, name: str) -> dict[str, Any]:
15
+ if group not in {"schemas", "scenarios", "examples"} or not _RESOURCE_NAME.fullmatch(name):
16
+ raise _asset_not_found(group, name)
17
+ resource = resources.files("taco.assets").joinpath(group, f"{name}.json")
18
+ if not resource.is_file():
19
+ raise _asset_not_found(group, name)
20
+ try:
21
+ payload = json.loads(resource.read_text(encoding="utf-8"))
22
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
23
+ raise TacoError(
24
+ code="ASSET_INVALID",
25
+ message=f"内置资源损坏:{group}/{name}",
26
+ exit_code=7,
27
+ ) from error
28
+ if not isinstance(payload, dict):
29
+ raise TacoError(
30
+ code="ASSET_INVALID",
31
+ message=f"内置资源不是 JSON 对象:{group}/{name}",
32
+ exit_code=7,
33
+ )
34
+ return payload
35
+
36
+
37
+ def list_json_assets(group: str) -> list[str]:
38
+ if group not in {"schemas", "scenarios", "examples"}:
39
+ raise _asset_not_found(group, "*")
40
+ root = resources.files("taco.assets").joinpath(group)
41
+ try:
42
+ return sorted(
43
+ resource.name.removesuffix(".json")
44
+ for resource in root.iterdir()
45
+ if resource.is_file()
46
+ and resource.name.endswith(".json")
47
+ and _RESOURCE_NAME.fullmatch(resource.name.removesuffix(".json"))
48
+ )
49
+ except OSError as error:
50
+ raise TacoError(
51
+ code="ASSET_INVALID",
52
+ message=f"无法读取内置资源目录:{group}",
53
+ exit_code=7,
54
+ ) from error
55
+
56
+
57
+ def load_skill_files() -> dict[str, str]:
58
+ root = resources.files("taco.assets").joinpath("skills", "taco")
59
+ files: dict[str, str] = {}
60
+ for name in ("SKILL.md", "manifest.json"):
61
+ resource = root.joinpath(name)
62
+ if not resource.is_file():
63
+ raise _asset_not_found("skills", f"taco/{name}")
64
+ try:
65
+ files[name] = resource.read_text(encoding="utf-8")
66
+ except (OSError, UnicodeError) as error:
67
+ raise TacoError(
68
+ code="ASSET_INVALID",
69
+ message=f"内置 Skill 资源损坏:{name}",
70
+ exit_code=7,
71
+ ) from error
72
+ return files
73
+
74
+
75
+ def _asset_not_found(group: str, name: str) -> TacoError:
76
+ return TacoError(
77
+ code="ASSET_NOT_FOUND",
78
+ message=f"未找到内置资源:{group}/{name}",
79
+ exit_code=6,
80
+ hint="请执行 taco help -o json 查看当前版本支持的资源。",
81
+ )
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from dataclasses import dataclass, field
7
+ from json import JSONDecodeError
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Literal
10
+
11
+ from taco.core.errors import TacoError
12
+ from taco.core.file_lock import (
13
+ DEFAULT_LOCK_TIMEOUT_SECONDS,
14
+ InterProcessFileLock,
15
+ )
16
+
17
+
18
+ Profile = dict[str, Any]
19
+ AuthToken = dict[str, str]
20
+ ConfigScope = Literal["global", "explicit"]
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class TacoConfig:
25
+ current_profile: str | None = None
26
+ profiles: dict[str, Profile] = field(default_factory=dict)
27
+ auth_tokens: dict[str, AuthToken] = field(default_factory=dict)
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ResolvedConfigPath:
32
+ path: Path
33
+ scope: ConfigScope
34
+
35
+
36
+ def resolve_config_path(
37
+ *,
38
+ path: Path | None = None,
39
+ ) -> ResolvedConfigPath:
40
+ """解析固定全局配置路径;显式路径仅供依赖注入和测试。"""
41
+ if path is not None:
42
+ resolved = path.expanduser()
43
+ if not resolved.is_absolute():
44
+ resolved = (Path.cwd() / resolved).resolve()
45
+ else:
46
+ resolved = resolved.resolve()
47
+ return ResolvedConfigPath(path=resolved, scope="explicit")
48
+
49
+ return ResolvedConfigPath(path=global_config_path(), scope="global")
50
+
51
+
52
+ def global_config_path() -> Path:
53
+ return Path.home() / ".taco" / "config.json"
54
+
55
+
56
+ class ConfigStore:
57
+ def __init__(
58
+ self,
59
+ path: Path | None = None,
60
+ *,
61
+ lock_timeout_seconds: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
62
+ ) -> None:
63
+ resolved = resolve_config_path(path=path)
64
+ self.path = resolved.path
65
+ self.scope: ConfigScope = resolved.scope
66
+ self.lock_path = self.path.with_name(f"{self.path.name}.lock")
67
+ self.lock_timeout_seconds = lock_timeout_seconds
68
+
69
+ def load(self) -> TacoConfig:
70
+ if not self.path.exists():
71
+ return TacoConfig()
72
+
73
+ try:
74
+ content = self.path.read_text(encoding="utf-8")
75
+ except OSError as error:
76
+ raise _invalid_config_error(self.path) from error
77
+ if not content.strip():
78
+ return TacoConfig()
79
+ try:
80
+ payload = json.loads(content)
81
+ except JSONDecodeError as error:
82
+ raise _invalid_config_error(self.path) from error
83
+ if not isinstance(payload, dict):
84
+ raise _invalid_config_error(self.path)
85
+ current_profile = payload.get("currentProfile")
86
+ profiles = payload.get("profiles", {})
87
+ auth_tokens = payload.get("authTokens", {})
88
+ if (
89
+ (current_profile is not None and not isinstance(current_profile, str))
90
+ or not isinstance(profiles, dict)
91
+ or not isinstance(auth_tokens, dict)
92
+ or any(
93
+ not isinstance(name, str) or not isinstance(value, dict)
94
+ for name, value in profiles.items()
95
+ )
96
+ or any(
97
+ not isinstance(name, str) or not isinstance(value, dict)
98
+ for name, value in auth_tokens.items()
99
+ )
100
+ ):
101
+ raise _invalid_config_error(self.path)
102
+ return TacoConfig(
103
+ current_profile=current_profile,
104
+ profiles=profiles,
105
+ auth_tokens=auth_tokens,
106
+ )
107
+
108
+ def save(self, config: TacoConfig) -> None:
109
+ with self._lock():
110
+ self._save_unlocked(config)
111
+
112
+ def update(
113
+ self,
114
+ mutator: Callable[[TacoConfig], TacoConfig | None],
115
+ ) -> TacoConfig:
116
+ """Atomically load, mutate and save the configuration under one lock."""
117
+ with self._lock():
118
+ config = self.load()
119
+ replacement = mutator(config)
120
+ if replacement is not None:
121
+ if not isinstance(replacement, TacoConfig):
122
+ raise TypeError("config mutator must return TacoConfig or None")
123
+ config = replacement
124
+ self._save_unlocked(config)
125
+ return config
126
+
127
+ def _lock(self) -> InterProcessFileLock:
128
+ return InterProcessFileLock(
129
+ self.lock_path,
130
+ timeout_seconds=self.lock_timeout_seconds,
131
+ )
132
+
133
+ def _save_unlocked(self, config: TacoConfig) -> None:
134
+ payload = {
135
+ "currentProfile": config.current_profile,
136
+ "profiles": config.profiles,
137
+ "authTokens": config.auth_tokens,
138
+ }
139
+ temporary: Path | None = None
140
+ try:
141
+ self.path.parent.mkdir(parents=True, exist_ok=True)
142
+ descriptor, temporary_name = tempfile.mkstemp(
143
+ dir=self.path.parent,
144
+ prefix=f".{self.path.name}.",
145
+ suffix=".tmp",
146
+ text=True,
147
+ )
148
+ temporary = Path(temporary_name)
149
+ os.chmod(temporary, 0o600)
150
+ with os.fdopen(descriptor, "w", encoding="utf-8") as file:
151
+ file.write(
152
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
153
+ )
154
+ file.flush()
155
+ os.fsync(file.fileno())
156
+ os.replace(temporary, self.path)
157
+ try:
158
+ os.chmod(self.path, 0o600)
159
+ except OSError:
160
+ pass
161
+ except OSError as error:
162
+ raise TacoError(
163
+ code="CONFIG_FILE_WRITE_FAILED",
164
+ message="无法写入 TACO 配置文件。",
165
+ exit_code=2,
166
+ hint=f"请检查目录权限和磁盘空间:{self.path}",
167
+ ) from error
168
+ finally:
169
+ if temporary is not None and temporary.exists():
170
+ try:
171
+ temporary.unlink()
172
+ except OSError:
173
+ pass
174
+
175
+ def get_profile(self, profile: str | None = None) -> Profile:
176
+ config = self.load()
177
+ profile_name = profile or config.current_profile
178
+ return config.profiles.get(profile_name or "", {})
179
+
180
+ def get_auth_token(self, profile_name: str) -> AuthToken | None:
181
+ token = self.load().auth_tokens.get(profile_name)
182
+ return dict(token) if token is not None else None
183
+
184
+ def update_auth_token(self, profile_name: str, token: AuthToken) -> None:
185
+ def mutate(config: TacoConfig) -> None:
186
+ config.auth_tokens[profile_name] = dict(token)
187
+
188
+ self.update(mutate)
189
+
190
+ def delete_auth_token(self, profile_name: str) -> None:
191
+ def mutate(config: TacoConfig) -> None:
192
+ config.auth_tokens.pop(profile_name, None)
193
+
194
+ self.update(mutate)
195
+
196
+ def describe(self) -> dict[str, Any]:
197
+ return {
198
+ "config_path": str(self.path),
199
+ "config_scope": self.scope,
200
+ }
201
+
202
+
203
+ def _invalid_config_error(path: Path) -> TacoError:
204
+ return TacoError(
205
+ code="CONFIG_FILE_INVALID",
206
+ message="配置文件不是合法 JSON 对象。",
207
+ exit_code=2,
208
+ hint=f"请检查或删除配置文件:{path}",
209
+ )
taco/core/context.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import typer
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class CliContext:
10
+ profile: str | None = None
11
+ json_output: bool = False
12
+ no_input: bool = False
13
+
14
+
15
+ def get_cli_context(ctx: typer.Context) -> CliContext:
16
+ state = ctx.find_root().obj
17
+ if isinstance(state, CliContext):
18
+ return state
19
+ return CliContext()