codeer-cli 0.1.0__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.
- codeer_cli/__init__.py +54 -0
- codeer_cli/_validate.py +131 -0
- codeer_cli/agents.py +155 -0
- codeer_cli/chats.py +87 -0
- codeer_cli/cli.py +92 -0
- codeer_cli/client.py +277 -0
- codeer_cli/commands/__init__.py +0 -0
- codeer_cli/commands/_util.py +12 -0
- codeer_cli/commands/agent.py +186 -0
- codeer_cli/commands/check.py +66 -0
- codeer_cli/commands/eval_cmd.py +919 -0
- codeer_cli/commands/history.py +200 -0
- codeer_cli/commands/kb.py +126 -0
- codeer_cli/commands/profile.py +205 -0
- codeer_cli/constants.py +66 -0
- codeer_cli/eval_.py +423 -0
- codeer_cli/histories.py +156 -0
- codeer_cli/kb.py +226 -0
- codeer_cli/parse.py +567 -0
- codeer_cli-0.1.0.dist-info/METADATA +108 -0
- codeer_cli-0.1.0.dist-info/RECORD +23 -0
- codeer_cli-0.1.0.dist-info/WHEEL +4 -0
- codeer_cli-0.1.0.dist-info/entry_points.txt +2 -0
codeer_cli/client.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""HTTP client for the Codeer API.
|
|
2
|
+
|
|
3
|
+
Auth uses a workspace API key supplied through ``CODEER_API_KEY`` or a named
|
|
4
|
+
profile stored outside the workspace. ``CODEER_API_BASE`` defaults to
|
|
5
|
+
production and can be overridden for local, beta, or preview. The CLI
|
|
6
|
+
intentionally does not read workspace-local dotenv files or credential files,
|
|
7
|
+
because those locations are commonly visible to LLM workspace context.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json as json_lib
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Iterable, Iterator, Mapping, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
DEFAULT_CODEER_API_BASE = "https://api.codeer.ai"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodeerError(RuntimeError):
|
|
23
|
+
"""Raised when the server returns a non-2xx response or an error envelope."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status: int, message: str, body: Any = None):
|
|
26
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
27
|
+
self.status = status
|
|
28
|
+
self.message = message
|
|
29
|
+
self.body = body
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthError(CodeerError):
|
|
33
|
+
"""Raised when the API key is missing, expired, revoked, or rejected."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ScopeResolutionError(CodeerError):
|
|
37
|
+
"""Raised when workspace or organization scope cannot be inferred."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CodeerClient:
|
|
42
|
+
"""Thin wrapper around httpx.Client with Codeer API-key auth.
|
|
43
|
+
|
|
44
|
+
Construct via ``CodeerClient.from_env()`` so your script picks up credentials
|
|
45
|
+
from the process environment without hardcoding them.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
base_url: str
|
|
49
|
+
api_key: str
|
|
50
|
+
workspace_id: Optional[str] = None
|
|
51
|
+
organization_id: Optional[str] = None
|
|
52
|
+
agent_id: Optional[str] = None
|
|
53
|
+
timeout: float = 30.0
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
self._me_cache: Optional[dict[str, Any]] = None
|
|
57
|
+
self._client = httpx.Client(
|
|
58
|
+
base_url=self.base_url.rstrip("/"),
|
|
59
|
+
timeout=self.timeout,
|
|
60
|
+
headers={
|
|
61
|
+
"x-api-key": self.api_key,
|
|
62
|
+
"Accept": "application/json",
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_env(cls, **overrides: Any) -> "CodeerClient":
|
|
68
|
+
base_url = overrides.pop("base_url", None)
|
|
69
|
+
api_key = overrides.pop("api_key", None)
|
|
70
|
+
|
|
71
|
+
if not api_key:
|
|
72
|
+
from .commands.profile import resolve_profile
|
|
73
|
+
|
|
74
|
+
profile = resolve_profile()
|
|
75
|
+
api_key = profile.get("api_key")
|
|
76
|
+
base_url = (
|
|
77
|
+
base_url
|
|
78
|
+
or os.environ.get("CODEER_API_BASE")
|
|
79
|
+
or profile.get("api_base")
|
|
80
|
+
or DEFAULT_CODEER_API_BASE
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
base_url = base_url or os.environ.get("CODEER_API_BASE") or DEFAULT_CODEER_API_BASE
|
|
84
|
+
|
|
85
|
+
if not api_key:
|
|
86
|
+
raise AuthError(
|
|
87
|
+
0,
|
|
88
|
+
"Missing API key. Export CODEER_API_KEY or run `codeer profile add <name>` "
|
|
89
|
+
"and `codeer profile use <name>`.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
overrides.pop("workspace_id", None)
|
|
93
|
+
overrides.pop("organization_id", None)
|
|
94
|
+
|
|
95
|
+
return cls(
|
|
96
|
+
base_url=base_url,
|
|
97
|
+
api_key=api_key,
|
|
98
|
+
agent_id=overrides.pop("agent_id", None) or os.environ.get("CODEER_AGENT_ID") or None,
|
|
99
|
+
**overrides,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def get_me(self) -> dict[str, Any]:
|
|
103
|
+
if self._me_cache is None:
|
|
104
|
+
self._me_cache = self.get("/external/me")
|
|
105
|
+
return self._me_cache
|
|
106
|
+
|
|
107
|
+
def resolve_scope(self) -> tuple[str, str]:
|
|
108
|
+
"""Resolve workspace/org from the API-key virtual user's profile."""
|
|
109
|
+
me = self.get_me()
|
|
110
|
+
profile = me.get("profile") or {}
|
|
111
|
+
ws_id = profile.get("default_workspace_id")
|
|
112
|
+
org_id = profile.get("default_organization_id")
|
|
113
|
+
|
|
114
|
+
if not ws_id or not org_id:
|
|
115
|
+
default_scopes = profile.get("default_scopes") or {}
|
|
116
|
+
ws_org_map = {str(k): str(v) for k, v in (profile.get("workspace_organization_map") or {}).items()}
|
|
117
|
+
candidates = _workspace_candidates(default_scopes, ws_org_map)
|
|
118
|
+
detail = ""
|
|
119
|
+
if candidates:
|
|
120
|
+
detail = "\nAvailable workspace candidates from profile:\n" + _format_workspace_choices(
|
|
121
|
+
candidates, ws_org_map, _workspace_names(profile)
|
|
122
|
+
)
|
|
123
|
+
raise ScopeResolutionError(
|
|
124
|
+
0,
|
|
125
|
+
"API key profile is missing default_workspace_id or default_organization_id. "
|
|
126
|
+
"This CLI expects a workspace API key virtual user profile."
|
|
127
|
+
+ detail,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
self.workspace_id = str(ws_id)
|
|
131
|
+
self.organization_id = str(org_id)
|
|
132
|
+
return self.workspace_id, self.organization_id
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
self._client.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> "CodeerClient":
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *exc: Any) -> None:
|
|
141
|
+
self.close()
|
|
142
|
+
|
|
143
|
+
def request(
|
|
144
|
+
self,
|
|
145
|
+
method: str,
|
|
146
|
+
path: str,
|
|
147
|
+
*,
|
|
148
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
149
|
+
json: Any = None,
|
|
150
|
+
files: Any = None,
|
|
151
|
+
data: Any = None,
|
|
152
|
+
) -> Any:
|
|
153
|
+
url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
|
|
154
|
+
r = self._client.request(method, url, params=params, json=json, files=files, data=data)
|
|
155
|
+
return self._parse(r)
|
|
156
|
+
|
|
157
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
158
|
+
return self.request("GET", path, **kwargs)
|
|
159
|
+
|
|
160
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
161
|
+
return self.request("POST", path, **kwargs)
|
|
162
|
+
|
|
163
|
+
def put(self, path: str, **kwargs: Any) -> Any:
|
|
164
|
+
return self.request("PUT", path, **kwargs)
|
|
165
|
+
|
|
166
|
+
def patch(self, path: str, **kwargs: Any) -> Any:
|
|
167
|
+
return self.request("PATCH", path, **kwargs)
|
|
168
|
+
|
|
169
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
170
|
+
return self.request("DELETE", path, **kwargs)
|
|
171
|
+
|
|
172
|
+
def stream_sse(
|
|
173
|
+
self,
|
|
174
|
+
method: str,
|
|
175
|
+
path: str,
|
|
176
|
+
*,
|
|
177
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
178
|
+
json: Any = None,
|
|
179
|
+
) -> Iterator[dict]:
|
|
180
|
+
"""Yield parsed SSE events from a streaming endpoint (e.g. POST /chats/{id}/messages).
|
|
181
|
+
|
|
182
|
+
Each event is a dict like ``{"event": "message", "data": <parsed-json-or-str>}``.
|
|
183
|
+
"""
|
|
184
|
+
url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
|
|
185
|
+
with self._client.stream(method, url, params=params, json=json) as r:
|
|
186
|
+
if r.status_code >= 400:
|
|
187
|
+
body = r.read().decode("utf-8", "replace")
|
|
188
|
+
self._raise_for_error(r.status_code, body)
|
|
189
|
+
event = "message"
|
|
190
|
+
buf: list[str] = []
|
|
191
|
+
for line in r.iter_lines():
|
|
192
|
+
if line == "":
|
|
193
|
+
if buf:
|
|
194
|
+
raw = "\n".join(buf)
|
|
195
|
+
yield {"event": event, "data": _maybe_json(raw)}
|
|
196
|
+
buf = []
|
|
197
|
+
event = "message"
|
|
198
|
+
continue
|
|
199
|
+
if line.startswith(":"):
|
|
200
|
+
continue
|
|
201
|
+
if line.startswith("event:"):
|
|
202
|
+
event = line[len("event:"):].strip()
|
|
203
|
+
continue
|
|
204
|
+
if line.startswith("data:"):
|
|
205
|
+
buf.append(line[len("data:"):].lstrip())
|
|
206
|
+
if buf:
|
|
207
|
+
yield {"event": event, "data": _maybe_json("\n".join(buf))}
|
|
208
|
+
|
|
209
|
+
def _parse(self, r: httpx.Response) -> Any:
|
|
210
|
+
text = r.text
|
|
211
|
+
try:
|
|
212
|
+
payload = r.json() if text else None
|
|
213
|
+
except ValueError:
|
|
214
|
+
payload = text
|
|
215
|
+
|
|
216
|
+
if r.status_code >= 400:
|
|
217
|
+
self._raise_for_error(r.status_code, payload)
|
|
218
|
+
|
|
219
|
+
# Ninja responses follow {error_code, message, data, pagination}. Unwrap `data`
|
|
220
|
+
# when present and the envelope indicates success, but return the full envelope
|
|
221
|
+
# if callers need the pagination cursor or error_code detail.
|
|
222
|
+
if isinstance(payload, dict) and "error_code" in payload and "data" in payload:
|
|
223
|
+
if payload.get("error_code") not in (0, None):
|
|
224
|
+
raise CodeerError(r.status_code, payload.get("message") or "error", payload)
|
|
225
|
+
return payload["data"]
|
|
226
|
+
return payload
|
|
227
|
+
|
|
228
|
+
def _raise_for_error(self, status: int, payload: Any) -> None:
|
|
229
|
+
message = payload if isinstance(payload, str) else (payload or {}).get("message") if isinstance(payload, dict) else None
|
|
230
|
+
if status in (401, 403):
|
|
231
|
+
raise AuthError(
|
|
232
|
+
status,
|
|
233
|
+
f"{message or 'auth rejected'}. API key may be missing, invalid, expired, or revoked.",
|
|
234
|
+
payload,
|
|
235
|
+
)
|
|
236
|
+
raise CodeerError(status, message or f"HTTP {status}", payload)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _maybe_json(raw: str) -> Any:
|
|
240
|
+
try:
|
|
241
|
+
return json_lib.loads(raw)
|
|
242
|
+
except (ValueError, TypeError):
|
|
243
|
+
return raw
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _workspace_names(profile: Mapping[str, Any]) -> dict[str, str]:
|
|
247
|
+
names: dict[str, str] = {}
|
|
248
|
+
for ws in profile.get("workspaces") or []:
|
|
249
|
+
ws_id = ws.get("id")
|
|
250
|
+
if ws_id:
|
|
251
|
+
names[str(ws_id)] = str(ws.get("name") or "(unnamed)")
|
|
252
|
+
return names
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _workspace_candidates(default_scopes: Mapping[str, Any], ws_org_map: Mapping[str, str]) -> list[str]:
|
|
256
|
+
candidates: set[str] = set()
|
|
257
|
+
for scope in default_scopes.values():
|
|
258
|
+
if isinstance(scope, Mapping) and scope.get("workspace_id"):
|
|
259
|
+
candidates.add(str(scope["workspace_id"]))
|
|
260
|
+
candidates.update(str(ws_id) for ws_id in ws_org_map.keys())
|
|
261
|
+
return sorted(candidates)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _format_workspace_choices(
|
|
265
|
+
workspace_ids: Iterable[str],
|
|
266
|
+
ws_org_map: Mapping[str, str],
|
|
267
|
+
workspace_names: Mapping[str, str],
|
|
268
|
+
) -> str:
|
|
269
|
+
lines = []
|
|
270
|
+
for ws_id in workspace_ids:
|
|
271
|
+
name = workspace_names.get(ws_id)
|
|
272
|
+
org = ws_org_map.get(ws_id)
|
|
273
|
+
label = f"{name} ({ws_id})" if name else ws_id
|
|
274
|
+
if org:
|
|
275
|
+
label = f"{label} in org {org}"
|
|
276
|
+
lines.append(f" - {label}")
|
|
277
|
+
return "\n".join(lines)
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def log(*args, **kwargs):
|
|
7
|
+
print(*args, file=sys.stderr, **kwargs)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def truncate(text: str, n: int = 60) -> str:
|
|
11
|
+
text = text.replace("\n", " ").strip()
|
|
12
|
+
return text[:n] + "..." if len(text) > n else text
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .. import agents as agents_mod
|
|
10
|
+
from ..client import CodeerClient
|
|
11
|
+
from ._util import log
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(subparsers):
|
|
15
|
+
agent = subparsers.add_parser("agent", help="Agent CRUD, versioning, publishing")
|
|
16
|
+
sub = agent.add_subparsers(dest="action", required=True)
|
|
17
|
+
|
|
18
|
+
# codeer agent list
|
|
19
|
+
p = sub.add_parser("list", help="List agents in workspace")
|
|
20
|
+
p.set_defaults(func=run_list)
|
|
21
|
+
|
|
22
|
+
# codeer agent get <id>
|
|
23
|
+
p = sub.add_parser("get", help="Read agent details")
|
|
24
|
+
p.add_argument("agent_id")
|
|
25
|
+
p.set_defaults(func=run_get)
|
|
26
|
+
|
|
27
|
+
# codeer agent apply --payload agent.json
|
|
28
|
+
p = sub.add_parser("apply", help="Create or update agent from JSON payload")
|
|
29
|
+
p.add_argument("--payload", required=True, help="Path to agent payload JSON")
|
|
30
|
+
p.add_argument("--agent-id", default=None, help="If set, PUT (update). Else POST (create).")
|
|
31
|
+
p.add_argument("--note", default="", help="version_note for PUT")
|
|
32
|
+
p.add_argument("--out", default=None, help="Write result JSON to this file too")
|
|
33
|
+
p.set_defaults(func=run_apply)
|
|
34
|
+
|
|
35
|
+
# codeer agent diff --from-version 41 --to-version 42
|
|
36
|
+
p = sub.add_parser("diff", help="Diff system_prompt + tools between two versions")
|
|
37
|
+
p.add_argument("--agent", required=True)
|
|
38
|
+
p.add_argument("--from", dest="frm", default=None, help="From history_id")
|
|
39
|
+
p.add_argument("--to", default=None, help="To history_id")
|
|
40
|
+
p.add_argument("--from-version", type=int, default=None)
|
|
41
|
+
p.add_argument("--to-version", type=int, default=None)
|
|
42
|
+
p.add_argument("--field", choices=("system_prompt", "tools", "all"), default="all")
|
|
43
|
+
p.set_defaults(func=run_diff)
|
|
44
|
+
|
|
45
|
+
# codeer agent versions --agent <id>
|
|
46
|
+
p = sub.add_parser("versions", help="List version history for an agent")
|
|
47
|
+
p.add_argument("--agent", required=True)
|
|
48
|
+
p.set_defaults(func=run_versions)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_list(args, client) -> int:
|
|
53
|
+
ws, org = client.resolve_scope()
|
|
54
|
+
result = agents_mod.list_all(client, workspace_id=ws, organization_id=org)
|
|
55
|
+
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_get(args, client) -> int:
|
|
60
|
+
result = agents_mod.get(client, args.agent_id)
|
|
61
|
+
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_apply(args, client) -> int:
|
|
66
|
+
body = json.loads(Path(args.payload).read_text())
|
|
67
|
+
|
|
68
|
+
if args.agent_id:
|
|
69
|
+
body.pop("workspace_id", None)
|
|
70
|
+
agents_mod.update(
|
|
71
|
+
client, args.agent_id,
|
|
72
|
+
name=body["name"],
|
|
73
|
+
system_prompt=body["system_prompt"],
|
|
74
|
+
unified_tools=body.get("unified_tools") or [],
|
|
75
|
+
use_search=body.get("use_search", False),
|
|
76
|
+
version_note=args.note,
|
|
77
|
+
description=body.get("description"),
|
|
78
|
+
llm_model=body.get("llm_model"),
|
|
79
|
+
suggested_questions=body.get("suggested_questions") or [],
|
|
80
|
+
primary_object_ids=body.get("primary_object_ids") or [],
|
|
81
|
+
attachment_ids=body.get("attachment_ids") or [],
|
|
82
|
+
)
|
|
83
|
+
agent_id = args.agent_id
|
|
84
|
+
log(f"PUT /agents/{agent_id} ok")
|
|
85
|
+
else:
|
|
86
|
+
if not body.get("workspace_id"):
|
|
87
|
+
body["workspace_id"] = client.resolve_scope()[0]
|
|
88
|
+
agent = agents_mod.create(
|
|
89
|
+
client,
|
|
90
|
+
workspace_id=body["workspace_id"],
|
|
91
|
+
name=body["name"],
|
|
92
|
+
system_prompt=body["system_prompt"],
|
|
93
|
+
unified_tools=body.get("unified_tools") or [],
|
|
94
|
+
use_search=body.get("use_search", False),
|
|
95
|
+
description=body.get("description"),
|
|
96
|
+
llm_model=body.get("llm_model"),
|
|
97
|
+
suggested_questions=body.get("suggested_questions") or [],
|
|
98
|
+
primary_object_ids=body.get("primary_object_ids") or [],
|
|
99
|
+
attachment_ids=body.get("attachment_ids") or [],
|
|
100
|
+
)
|
|
101
|
+
agent_id = agent["id"]
|
|
102
|
+
log(f"POST /agents ok, id={agent_id}")
|
|
103
|
+
|
|
104
|
+
histories = agents_mod.list_versions(client, agent_id)
|
|
105
|
+
latest = max(histories, key=lambda h: h.get("version_number", 0))
|
|
106
|
+
result = {
|
|
107
|
+
"agent_id": agent_id,
|
|
108
|
+
"history_id": latest["id"],
|
|
109
|
+
"version_number": latest.get("version_number"),
|
|
110
|
+
"status": latest.get("status"),
|
|
111
|
+
}
|
|
112
|
+
out_text = json.dumps(result, indent=2)
|
|
113
|
+
print(out_text)
|
|
114
|
+
if args.out:
|
|
115
|
+
Path(args.out).write_text(out_text + "\n")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run_versions(args, client) -> int:
|
|
120
|
+
versions = agents_mod.list_versions(client, args.agent)
|
|
121
|
+
print(json.dumps(versions, ensure_ascii=False, indent=2, default=str))
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- diff helpers ---
|
|
127
|
+
|
|
128
|
+
def _resolve(c: CodeerClient, agent_id: str, hid: Optional[str], version: Optional[int]) -> dict:
|
|
129
|
+
if hid:
|
|
130
|
+
return agents_mod.get_version(c, agent_id, hid)
|
|
131
|
+
if version is not None:
|
|
132
|
+
for v in agents_mod.list_versions(c, agent_id):
|
|
133
|
+
if v.get("version_number") == version:
|
|
134
|
+
return agents_mod.get_version(c, agent_id, v["id"])
|
|
135
|
+
raise SystemExit(f"no version {version} on agent {agent_id}")
|
|
136
|
+
raise SystemExit("must pass --from/--to (history id) or --from-version/--to-version")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _label(snap: dict) -> str:
|
|
140
|
+
vn = snap.get("version_number")
|
|
141
|
+
note = (snap.get("version_note") or "").strip().replace("\n", " ")
|
|
142
|
+
pub = " (published)" if snap.get("status") == "published" else ""
|
|
143
|
+
return f"v{vn}{pub}: {note[:60]}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _diff_text(a: str, b: str, label_a: str, label_b: str) -> str:
|
|
147
|
+
return "".join(difflib.unified_diff(
|
|
148
|
+
(a or "").splitlines(keepends=True),
|
|
149
|
+
(b or "").splitlines(keepends=True),
|
|
150
|
+
fromfile=label_a, tofile=label_b,
|
|
151
|
+
))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _summarize_tool(t: dict) -> str:
|
|
155
|
+
keep_keys = ("type", "name", "description", "invocation_instruction",
|
|
156
|
+
"knowledge_node_ids", "domain", "agent_id",
|
|
157
|
+
"custom_form_schema", "http_request")
|
|
158
|
+
safe = {k: t.get(k) for k in keep_keys if t.get(k) is not None}
|
|
159
|
+
return json.dumps(safe, ensure_ascii=False, indent=2, sort_keys=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_diff(args, client) -> int:
|
|
163
|
+
snap_a = _resolve(client, args.agent, args.frm, args.from_version)
|
|
164
|
+
snap_b = _resolve(client, args.agent, args.to, args.to_version)
|
|
165
|
+
la, lb = _label(snap_a), _label(snap_b)
|
|
166
|
+
|
|
167
|
+
if args.field in ("system_prompt", "all"):
|
|
168
|
+
d = _diff_text(snap_a.get("system_prompt", ""), snap_b.get("system_prompt", ""),
|
|
169
|
+
f"{la} [system_prompt]", f"{lb} [system_prompt]")
|
|
170
|
+
if d.strip():
|
|
171
|
+
print(d)
|
|
172
|
+
else:
|
|
173
|
+
print(f"# system_prompt unchanged between {la} and {lb}")
|
|
174
|
+
|
|
175
|
+
if args.field in ("tools", "all"):
|
|
176
|
+
a_tools = snap_a.get("unified_tools") or []
|
|
177
|
+
b_tools = snap_b.get("unified_tools") or []
|
|
178
|
+
a_text = "\n".join(_summarize_tool(t) for t in a_tools) + "\n"
|
|
179
|
+
b_text = "\n".join(_summarize_tool(t) for t in b_tools) + "\n"
|
|
180
|
+
d = _diff_text(a_text, b_text, f"{la} [tools]", f"{lb} [tools]")
|
|
181
|
+
if d.strip():
|
|
182
|
+
print(d)
|
|
183
|
+
else:
|
|
184
|
+
print(f"# tools unchanged between {la} and {lb}")
|
|
185
|
+
|
|
186
|
+
return 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from ..client import AuthError, CodeerError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register(subparsers):
|
|
9
|
+
p = subparsers.add_parser("check", help="Validate auth, workspace, and agent config")
|
|
10
|
+
p.set_defaults(func=run)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(args, client) -> int:
|
|
14
|
+
errors = []
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
me = client.get_me()
|
|
18
|
+
except AuthError:
|
|
19
|
+
print("FAIL Auth: API key missing, invalid, expired, or revoked (401/403)", file=sys.stderr)
|
|
20
|
+
print(" Create an admin workspace API key and export CODEER_API_KEY before running codeer", file=sys.stderr)
|
|
21
|
+
return 1
|
|
22
|
+
except CodeerError as e:
|
|
23
|
+
print(f"FAIL Auth: {e}", file=sys.stderr)
|
|
24
|
+
return 1
|
|
25
|
+
|
|
26
|
+
profile = me.get("profile", {})
|
|
27
|
+
email = me.get("email") or "(unknown)"
|
|
28
|
+
print(f" OK Auth: logged in as {email}")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
ws_id, org_id = client.resolve_scope()
|
|
32
|
+
except CodeerError as e:
|
|
33
|
+
errors.append(f"FAIL Scope: {e.message if hasattr(e, 'message') else str(e)}")
|
|
34
|
+
ws_id, org_id = None, None
|
|
35
|
+
|
|
36
|
+
if ws_id:
|
|
37
|
+
ws_name = _workspace_name(profile, ws_id)
|
|
38
|
+
if ws_name:
|
|
39
|
+
print(f" OK Workspace: {ws_name} ({ws_id})")
|
|
40
|
+
else:
|
|
41
|
+
print(f" OK Workspace: {ws_id}")
|
|
42
|
+
|
|
43
|
+
if org_id:
|
|
44
|
+
print(f" OK Organization: {org_id}")
|
|
45
|
+
|
|
46
|
+
agent_id = client.agent_id
|
|
47
|
+
if agent_id:
|
|
48
|
+
try:
|
|
49
|
+
agent = client.get(f"/external/agents/{agent_id}")
|
|
50
|
+
print(f" OK Agent: {agent.get('name', '(unnamed)')} ({agent_id})")
|
|
51
|
+
except CodeerError:
|
|
52
|
+
errors.append(f"WARN Agent: ID {agent_id} could not be read (may be in a different workspace)")
|
|
53
|
+
else:
|
|
54
|
+
print(" -- Agent: CODEER_AGENT_ID not set (optional)")
|
|
55
|
+
|
|
56
|
+
for err in errors:
|
|
57
|
+
print(err, file=sys.stderr)
|
|
58
|
+
|
|
59
|
+
return 1 if any(e.startswith("FAIL") for e in errors) else 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _workspace_name(profile: dict, workspace_id: str) -> str | None:
|
|
63
|
+
for ws in profile.get("workspaces", []) or []:
|
|
64
|
+
if str(ws.get("id")) == str(workspace_id):
|
|
65
|
+
return ws.get("name")
|
|
66
|
+
return None
|