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/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