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.
- taco/__init__.py +1 -0
- taco/agent_openapi/__init__.py +1 -0
- taco/agent_openapi/catalog.py +198 -0
- taco/agent_openapi/models.py +47 -0
- taco/agent_openapi/parameters.py +111 -0
- taco/agent_openapi/service.py +73 -0
- taco/aikb/__init__.py +4 -0
- taco/aikb/catalog.py +380 -0
- taco/aikb/executors.py +157 -0
- taco/aikb/file_input.py +55 -0
- taco/aikb/models.py +42 -0
- taco/aikb/parameters.py +135 -0
- taco/aikb/service.py +68 -0
- taco/assets/__init__.py +1 -0
- taco/assets/agent_openapi/catalog.json +14 -0
- taco/assets/agent_openapi/operations/conversation/list-conversations.json +67 -0
- taco/assets/agent_openapi/operations/message/list-conversation-messages.json +80 -0
- taco/assets/agent_openapi/operations/trace/list-message-traces.json +72 -0
- taco/assets/aikb/catalog.json +41 -0
- taco/assets/aikb/operations/conversation/chat-conversation-on-open.json +99 -0
- taco/assets/aikb/operations/directory/delete-directory.json +81 -0
- taco/assets/aikb/operations/directory/edit-directory.json +97 -0
- taco/assets/aikb/operations/directory/list-directory-tree.json +106 -0
- taco/assets/aikb/operations/directory/save-directory.json +109 -0
- taco/assets/aikb/operations/faq/create-faq.json +193 -0
- taco/assets/aikb/operations/faq/delete-faq.json +81 -0
- taco/assets/aikb/operations/faq/describe-faq.json +82 -0
- taco/assets/aikb/operations/faq/edit-faq.json +193 -0
- taco/assets/aikb/operations/faq/list-faqs.json +136 -0
- taco/assets/aikb/operations/file/create-file.json +145 -0
- taco/assets/aikb/operations/file/delete-files.json +110 -0
- taco/assets/aikb/operations/file/describe-file.json +82 -0
- taco/assets/aikb/operations/file/get-file-upload-url.json +154 -0
- taco/assets/aikb/operations/file/list-files.json +146 -0
- taco/assets/aikb/operations/media/describe-faq-media-url.json +98 -0
- taco/assets/aikb/operations/media/describe-file-media-url.json +90 -0
- taco/assets/aikb/operations/oss/signature-for-upload.json +85 -0
- taco/assets/aikb/operations/recycle-bin/list-recycled-items.json +151 -0
- taco/assets/aikb/operations/repository/describe-bot-repository.json +71 -0
- taco/assets/aikb/operations/repository/list-private-repositories.json +75 -0
- taco/assets/aikb/operations/repository/list-public-repositories.json +75 -0
- taco/assets/aikb/operations/search/search-knowledge-on-open.json +180 -0
- taco/assets/examples/README.md +3 -0
- taco/assets/examples/agent-basic.json +16 -0
- taco/assets/examples/agent-builtin-tool.json +28 -0
- taco/assets/examples/agent-workflow-tool.json +30 -0
- taco/assets/examples/chatflow-basic.json +24 -0
- taco/assets/examples/workflow-http.json +34 -0
- taco/assets/manifest.json +12 -0
- taco/assets/scenarios/README.md +3 -0
- taco/assets/scenarios/agent-basic.json +80 -0
- taco/assets/scenarios/agent-builtin-tool.json +116 -0
- taco/assets/scenarios/agent-workflow-tool.json +277 -0
- taco/assets/schemas/README.md +3 -0
- taco/assets/schemas/agent-tool.json +71 -0
- taco/assets/schemas/agent.json +199 -0
- taco/assets/schemas/chatflow.json +1048 -0
- taco/assets/schemas/workflow.http.json +1052 -0
- taco/assets/schemas/workflow.json +1052 -0
- taco/assets/skills/README.md +3 -0
- taco/assets/skills/taco/SKILL.md +68 -0
- taco/assets/skills/taco/manifest.json +10 -0
- taco/cli.py +127 -0
- taco/commands/__init__.py +1 -0
- taco/commands/agent.py +760 -0
- taco/commands/aikb.py +132 -0
- taco/commands/app.py +151 -0
- taco/commands/category.py +37 -0
- taco/commands/chatflow.py +183 -0
- taco/commands/credential.py +222 -0
- taco/commands/discovery.py +409 -0
- taco/commands/model.py +55 -0
- taco/commands/profile.py +124 -0
- taco/commands/tool.py +61 -0
- taco/commands/workflow.py +714 -0
- taco/core/__init__.py +1 -0
- taco/core/access_token_manager.py +98 -0
- taco/core/api_client.py +263 -0
- taco/core/assets.py +81 -0
- taco/core/config_store.py +209 -0
- taco/core/context.py +19 -0
- taco/core/developer_center_client.py +165 -0
- taco/core/errors.py +19 -0
- taco/core/file_lock.py +80 -0
- taco/core/http_headers.py +21 -0
- taco/core/openapi_signer.py +221 -0
- taco/core/output.py +72 -0
- taco/core/profile_manager.py +217 -0
- taco/core/profile_resolver.py +151 -0
- taco/core/secure_file.py +76 -0
- taco/release.py +363 -0
- taco/services/__init__.py +1 -0
- taco/services/agent_service.py +314 -0
- taco/services/app_service.py +153 -0
- taco/services/category_service.py +57 -0
- taco/services/model_service.py +117 -0
- taco/services/tool_service.py +482 -0
- taco/services/workflow_compiler.py +1665 -0
- taco/services/workflow_service.py +652 -0
- taco/services/workflow_tool_service.py +439 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/METADATA +158 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/RECORD +104 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/WHEEL +4 -0
- 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())
|
taco/core/api_client.py
ADDED
|
@@ -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()
|