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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from .. import agents as agents_mod
|
|
7
|
+
from .. import chats as chats_mod
|
|
8
|
+
from .. import histories as hist_mod
|
|
9
|
+
from ._util import log
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(subparsers):
|
|
13
|
+
h = subparsers.add_parser("history", help="Conversation history analysis")
|
|
14
|
+
sub = h.add_subparsers(dest="action", required=True)
|
|
15
|
+
|
|
16
|
+
# codeer history list
|
|
17
|
+
p = sub.add_parser("list", help="List conversation histories")
|
|
18
|
+
p.add_argument("--agent", default=None)
|
|
19
|
+
p.add_argument("--user", default=None, help="Filter by external_user_id")
|
|
20
|
+
p.add_argument("--feedback", default=None, help="positive / negative / any")
|
|
21
|
+
p.add_argument("--exclude-users", default=None,
|
|
22
|
+
help="Comma-separated external_user_ids to exclude")
|
|
23
|
+
p.add_argument("--version", type=int, default=None,
|
|
24
|
+
help="Filter to histories created while this agent version was live (requires --agent)")
|
|
25
|
+
p.add_argument("--limit", type=int, default=500)
|
|
26
|
+
p.add_argument("--offset", type=int, default=0)
|
|
27
|
+
p.set_defaults(func=run_list)
|
|
28
|
+
|
|
29
|
+
# codeer history get <id>
|
|
30
|
+
p = sub.add_parser("get", help="Get single history metadata")
|
|
31
|
+
p.add_argument("history_id", type=int)
|
|
32
|
+
p.set_defaults(func=run_get)
|
|
33
|
+
|
|
34
|
+
# codeer history conversations <id>
|
|
35
|
+
p = sub.add_parser("conversations", help="Get all turns in a history")
|
|
36
|
+
p.add_argument("history_id", type=int)
|
|
37
|
+
p.set_defaults(func=run_conversations)
|
|
38
|
+
|
|
39
|
+
# codeer history negative-feedback
|
|
40
|
+
p = sub.add_parser("negative-feedback", help="Surface assistant turns with negative feedback")
|
|
41
|
+
p.add_argument("--agent", required=True)
|
|
42
|
+
p.add_argument("--exclude-users", default=None,
|
|
43
|
+
help="Comma-separated external_user_ids to exclude")
|
|
44
|
+
p.add_argument("--limit", type=int, default=500)
|
|
45
|
+
p.set_defaults(func=run_negative_feedback)
|
|
46
|
+
|
|
47
|
+
# codeer history create --agent <id> --message ...
|
|
48
|
+
p = sub.add_parser("create", help="Create a real persisted conversation history")
|
|
49
|
+
p.add_argument("--agent", default=None, help="Agent ID (defaults to CODEER_AGENT_ID)")
|
|
50
|
+
p.add_argument("--title", default=None, help="Conversation title")
|
|
51
|
+
p.add_argument("--user", default=None, help="external_user_id to associate with the history")
|
|
52
|
+
p.add_argument("--message", action="append", required=True,
|
|
53
|
+
help="User message to send. Repeat for multi-turn histories.")
|
|
54
|
+
p.set_defaults(func=run_create)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_exclude(raw: str | None) -> list[str]:
|
|
58
|
+
if not raw:
|
|
59
|
+
return []
|
|
60
|
+
return [x.strip() for x in raw.split(",") if x.strip()]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _version_window(client, agent_id: str, version_number: int) -> tuple[str | None, str | None]:
|
|
64
|
+
"""Compute (start, end) ISO timestamps for when a version was the live published one."""
|
|
65
|
+
versions = agents_mod.list_versions(client, agent_id)
|
|
66
|
+
published = sorted(
|
|
67
|
+
[v for v in versions if v.get("was_published") or v.get("status") == "published"],
|
|
68
|
+
key=lambda v: v.get("version_number") or 0,
|
|
69
|
+
)
|
|
70
|
+
target = None
|
|
71
|
+
next_version = None
|
|
72
|
+
for i, v in enumerate(published):
|
|
73
|
+
if v.get("version_number") == version_number:
|
|
74
|
+
target = v
|
|
75
|
+
if i + 1 < len(published):
|
|
76
|
+
next_version = published[i + 1]
|
|
77
|
+
break
|
|
78
|
+
if target is None:
|
|
79
|
+
return None, None
|
|
80
|
+
start = target.get("published_at") or target.get("created_at")
|
|
81
|
+
end = next_version.get("published_at") or next_version.get("created_at") if next_version else None
|
|
82
|
+
return start, end
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_list(args, client) -> int:
|
|
86
|
+
agent_id = args.agent or os.environ.get("CODEER_AGENT_ID")
|
|
87
|
+
exclude = _parse_exclude(args.exclude_users)
|
|
88
|
+
workspace_id, organization_id = client.resolve_scope()
|
|
89
|
+
|
|
90
|
+
if args.version is not None and not agent_id:
|
|
91
|
+
log("error: --version requires --agent")
|
|
92
|
+
return 2
|
|
93
|
+
|
|
94
|
+
rows = hist_mod.list(
|
|
95
|
+
client,
|
|
96
|
+
agent_id=agent_id,
|
|
97
|
+
workspace_id=workspace_id,
|
|
98
|
+
organization_id=organization_id,
|
|
99
|
+
external_user_id=args.user,
|
|
100
|
+
feedback_filter=args.feedback,
|
|
101
|
+
exclude_users=exclude,
|
|
102
|
+
limit=args.limit,
|
|
103
|
+
offset=args.offset,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if args.version is not None:
|
|
107
|
+
start, end = _version_window(client, agent_id, args.version)
|
|
108
|
+
if start is None:
|
|
109
|
+
log(f"error: version {args.version} not found among published versions")
|
|
110
|
+
return 2
|
|
111
|
+
log(f"filtering to version {args.version} window: {start} .. {end or 'now'}")
|
|
112
|
+
filtered = []
|
|
113
|
+
for h in rows:
|
|
114
|
+
created = h.get("created_at") or ""
|
|
115
|
+
if created < start:
|
|
116
|
+
continue
|
|
117
|
+
if end and created >= end:
|
|
118
|
+
continue
|
|
119
|
+
filtered.append(h)
|
|
120
|
+
rows = filtered
|
|
121
|
+
|
|
122
|
+
print(json.dumps(rows, ensure_ascii=False, indent=2, default=str))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_get(args, client) -> int:
|
|
127
|
+
result = hist_mod.get(client, args.history_id)
|
|
128
|
+
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run_conversations(args, client) -> int:
|
|
133
|
+
result = hist_mod.get_conversations(client, args.history_id)
|
|
134
|
+
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def run_negative_feedback(args, client) -> int:
|
|
139
|
+
exclude = _parse_exclude(args.exclude_users)
|
|
140
|
+
workspace_id, organization_id = client.resolve_scope()
|
|
141
|
+
rows = hist_mod.list_negative_feedback_turns(
|
|
142
|
+
client,
|
|
143
|
+
agent_id=args.agent,
|
|
144
|
+
workspace_id=workspace_id,
|
|
145
|
+
organization_id=organization_id,
|
|
146
|
+
exclude_users=exclude,
|
|
147
|
+
limit=args.limit,
|
|
148
|
+
)
|
|
149
|
+
print(json.dumps(rows, ensure_ascii=False, indent=2, default=str))
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _history_url(client, workspace_id: str, history_id: int) -> str:
|
|
154
|
+
base = client.base_url.rstrip("/")
|
|
155
|
+
if base.startswith("https://api."):
|
|
156
|
+
base = "https://" + base[len("https://api."):]
|
|
157
|
+
return f"{base}/workspaces/{workspace_id}/histories/{history_id}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_create(args, client) -> int:
|
|
161
|
+
agent_id = args.agent or os.environ.get("CODEER_AGENT_ID")
|
|
162
|
+
if not agent_id:
|
|
163
|
+
log("error: --agent is required or set CODEER_AGENT_ID")
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
workspace_id, _ = client.resolve_scope()
|
|
167
|
+
title = args.title or (args.message[0].strip()[:80] if args.message else "CLI conversation")
|
|
168
|
+
|
|
169
|
+
chat = chats_mod.create(
|
|
170
|
+
client,
|
|
171
|
+
agent_id=agent_id,
|
|
172
|
+
title=title,
|
|
173
|
+
external_user_id=args.user,
|
|
174
|
+
)
|
|
175
|
+
history_id = chat["id"]
|
|
176
|
+
message_results = []
|
|
177
|
+
|
|
178
|
+
for idx, message in enumerate(args.message, 1):
|
|
179
|
+
log(f"sending turn {idx}/{len(args.message)}")
|
|
180
|
+
result = chats_mod.send_published_agent_message(
|
|
181
|
+
client,
|
|
182
|
+
chat_id=history_id,
|
|
183
|
+
message=message,
|
|
184
|
+
agent_id=agent_id,
|
|
185
|
+
external_user_id=args.user,
|
|
186
|
+
stream=False,
|
|
187
|
+
)
|
|
188
|
+
message_results.append(result)
|
|
189
|
+
|
|
190
|
+
conversations = hist_mod.get_conversations(client, history_id)
|
|
191
|
+
out = {
|
|
192
|
+
"agent_id": agent_id,
|
|
193
|
+
"history_id": history_id,
|
|
194
|
+
"external_user_id": args.user,
|
|
195
|
+
"url": _history_url(client, workspace_id, history_id),
|
|
196
|
+
"messages": message_results,
|
|
197
|
+
"conversations": conversations,
|
|
198
|
+
}
|
|
199
|
+
print(json.dumps(out, ensure_ascii=False, indent=2, default=str))
|
|
200
|
+
return 0
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .. import kb as kb_mod
|
|
8
|
+
from ._util import log
|
|
9
|
+
|
|
10
|
+
POLL_INTERVAL = 3
|
|
11
|
+
POLL_TIMEOUT = 600
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(subparsers):
|
|
15
|
+
k = subparsers.add_parser("kb", help="Knowledge base operations")
|
|
16
|
+
sub = k.add_subparsers(dest="action", required=True)
|
|
17
|
+
|
|
18
|
+
# codeer kb list
|
|
19
|
+
p = sub.add_parser("list", help="List knowledge bases in workspace")
|
|
20
|
+
p.add_argument("--parent-id", default=None, help="List children of this node (omit for top-level KBs)")
|
|
21
|
+
p.set_defaults(func=run_list)
|
|
22
|
+
|
|
23
|
+
# codeer kb files
|
|
24
|
+
p = sub.add_parser("files", help="List files inside a knowledge base")
|
|
25
|
+
p.add_argument("--kb-id", required=True, help="KB node UUID to list files from")
|
|
26
|
+
p.set_defaults(func=run_files)
|
|
27
|
+
|
|
28
|
+
p = sub.add_parser("upload", help="Create/reuse KB and upload files from a directory")
|
|
29
|
+
p.add_argument("--dir", required=True, help="Directory containing files to upload")
|
|
30
|
+
p.add_argument("--name", required=True, help="KB display name (idempotent on name)")
|
|
31
|
+
p.add_argument("--description", default=None)
|
|
32
|
+
p.add_argument("--glob", default="*", help="File glob within --dir (default: all files)")
|
|
33
|
+
p.add_argument("--out", default=None, help="Write result JSON to this file too")
|
|
34
|
+
p.add_argument("--poll-timeout", type=int, default=POLL_TIMEOUT)
|
|
35
|
+
p.set_defaults(func=run_upload)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_list(args, client) -> int:
|
|
39
|
+
workspace_id, organization_id = client.resolve_scope()
|
|
40
|
+
nodes = kb_mod.list_nodes(
|
|
41
|
+
client, organization_id=organization_id, workspace_id=workspace_id,
|
|
42
|
+
parent_id=getattr(args, "parent_id", None),
|
|
43
|
+
)
|
|
44
|
+
print(json.dumps(nodes, ensure_ascii=False, indent=2, default=str))
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_files(args, client) -> int:
|
|
49
|
+
workspace_id, organization_id = client.resolve_scope()
|
|
50
|
+
nodes = kb_mod.list_nodes(
|
|
51
|
+
client, organization_id=organization_id, workspace_id=workspace_id,
|
|
52
|
+
parent_id=args.kb_id,
|
|
53
|
+
)
|
|
54
|
+
print(json.dumps(nodes, ensure_ascii=False, indent=2, default=str))
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_upload(args, client) -> int:
|
|
59
|
+
workspace_id, organization_id = client.resolve_scope()
|
|
60
|
+
kb_dir = Path(args.dir).resolve()
|
|
61
|
+
if not kb_dir.is_dir():
|
|
62
|
+
log(f"error: --dir {kb_dir} is not a directory")
|
|
63
|
+
return 2
|
|
64
|
+
|
|
65
|
+
files = sorted(p for p in kb_dir.glob(args.glob) if p.is_file())
|
|
66
|
+
if not files:
|
|
67
|
+
log(f"error: no files matched in {kb_dir} (glob={args.glob})")
|
|
68
|
+
return 2
|
|
69
|
+
log(f"uploading {len(files)} files to KB '{args.name}'")
|
|
70
|
+
|
|
71
|
+
existing = kb_mod.list_nodes(client, organization_id=organization_id, workspace_id=workspace_id)
|
|
72
|
+
match = next((n for n in existing if n.get("name") == args.name), None)
|
|
73
|
+
if match:
|
|
74
|
+
kb_id = match.get("node_id") or match.get("id")
|
|
75
|
+
log(f"reusing KB '{args.name}' id={kb_id}")
|
|
76
|
+
else:
|
|
77
|
+
created = kb_mod.create_kb(
|
|
78
|
+
client, organization_id=organization_id, workspace_id=workspace_id,
|
|
79
|
+
name=args.name, description=args.description,
|
|
80
|
+
)
|
|
81
|
+
kb_id = created.get("node_id") or created.get("id")
|
|
82
|
+
log(f"created KB '{args.name}' id={kb_id}")
|
|
83
|
+
|
|
84
|
+
t0 = time.time()
|
|
85
|
+
resp = kb_mod.upload_files(
|
|
86
|
+
client, organization_id=organization_id, workspace_id=workspace_id,
|
|
87
|
+
kb_id=kb_id, file_paths=[str(p) for p in files], parent_id=kb_id,
|
|
88
|
+
)
|
|
89
|
+
log(f"upload returned in {time.time()-t0:.1f}s, {len(resp.get('nodes', []))} nodes")
|
|
90
|
+
|
|
91
|
+
nodes = resp.get("nodes", [])
|
|
92
|
+
node_ids = [n["node_id"] for n in nodes if n.get("node_id")]
|
|
93
|
+
name_to_id = {n.get("original_name", "?"): n.get("node_id") for n in nodes}
|
|
94
|
+
if not node_ids:
|
|
95
|
+
log("error: no node_ids returned from upload")
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
deadline = time.time() + args.poll_timeout
|
|
99
|
+
last_status = []
|
|
100
|
+
while time.time() < deadline:
|
|
101
|
+
last_status = kb_mod.file_status(
|
|
102
|
+
client, organization_id=organization_id, workspace_id=workspace_id, node_ids=node_ids,
|
|
103
|
+
)
|
|
104
|
+
counts: dict[str, int] = {}
|
|
105
|
+
for s in last_status:
|
|
106
|
+
k = s.get("status", "?").upper()
|
|
107
|
+
counts[k] = counts.get(k, 0) + 1
|
|
108
|
+
log(f" status: {counts}")
|
|
109
|
+
terminal = sum(counts.get(k, 0) for k in ("READY", "FAILED", "ERROR"))
|
|
110
|
+
if terminal >= len(node_ids):
|
|
111
|
+
break
|
|
112
|
+
time.sleep(POLL_INTERVAL)
|
|
113
|
+
|
|
114
|
+
not_ready = [s for s in last_status if s.get("status", "").upper() != "READY"]
|
|
115
|
+
if not_ready:
|
|
116
|
+
log(f"warning: {len(not_ready)} files NOT in READY state")
|
|
117
|
+
for s in not_ready:
|
|
118
|
+
log(f" {s}")
|
|
119
|
+
|
|
120
|
+
result = {"kb_id": kb_id, "node_ids": node_ids, "name_to_id": name_to_id}
|
|
121
|
+
out_text = json.dumps(result, indent=2, ensure_ascii=False)
|
|
122
|
+
print(out_text)
|
|
123
|
+
if args.out:
|
|
124
|
+
Path(args.out).write_text(out_text + "\n")
|
|
125
|
+
log(f"wrote {args.out}")
|
|
126
|
+
return 0 if not not_ready else 1
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..client import DEFAULT_CODEER_API_BASE
|
|
12
|
+
|
|
13
|
+
CONFIG_DIR = ".codeer"
|
|
14
|
+
LOCAL_PROFILE_FILE = "profile"
|
|
15
|
+
GLOBAL_PROFILE_FILE = "profiles.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(subparsers):
|
|
19
|
+
p = subparsers.add_parser("profile", help="Manage named API-key profiles")
|
|
20
|
+
p.set_defaults(no_client=True)
|
|
21
|
+
sub = p.add_subparsers(dest="action")
|
|
22
|
+
|
|
23
|
+
current = sub.add_parser("current", help="Show the selected profile")
|
|
24
|
+
current.set_defaults(func=run_current, no_client=True)
|
|
25
|
+
|
|
26
|
+
list_ = sub.add_parser("list", help="List configured profiles")
|
|
27
|
+
list_.set_defaults(func=run_list, no_client=True)
|
|
28
|
+
|
|
29
|
+
add = sub.add_parser("add", help="Add or update a global profile")
|
|
30
|
+
add.add_argument("name")
|
|
31
|
+
add.add_argument("--api-base", default=DEFAULT_CODEER_API_BASE)
|
|
32
|
+
add.set_defaults(func=run_add, no_client=True)
|
|
33
|
+
|
|
34
|
+
use = sub.add_parser("use", help="Use a profile in this directory")
|
|
35
|
+
use.add_argument("name")
|
|
36
|
+
use.set_defaults(func=run_use, no_client=True)
|
|
37
|
+
|
|
38
|
+
default = sub.add_parser("default", help="Set the global default profile")
|
|
39
|
+
default.add_argument("name")
|
|
40
|
+
default.set_defaults(func=run_default, no_client=True)
|
|
41
|
+
|
|
42
|
+
remove = sub.add_parser("remove", help="Remove a global profile")
|
|
43
|
+
remove.add_argument("name")
|
|
44
|
+
remove.set_defaults(func=run_remove, no_client=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_profile() -> dict[str, str | None]:
|
|
48
|
+
if "CODEER_API_KEY" in os.environ:
|
|
49
|
+
return {
|
|
50
|
+
"name": os.environ.get("CODEER_PROFILE"),
|
|
51
|
+
"source": "environment",
|
|
52
|
+
"api_key": os.environ["CODEER_API_KEY"],
|
|
53
|
+
"api_base": os.environ.get("CODEER_API_BASE") or DEFAULT_CODEER_API_BASE,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
selected = os.environ.get("CODEER_PROFILE")
|
|
57
|
+
source = "CODEER_PROFILE" if selected else None
|
|
58
|
+
|
|
59
|
+
if not selected:
|
|
60
|
+
local = _find_local_profile(Path.cwd())
|
|
61
|
+
if local:
|
|
62
|
+
selected = local.read_text(encoding="utf-8").strip()
|
|
63
|
+
source = str(local)
|
|
64
|
+
|
|
65
|
+
data = _read_profiles()
|
|
66
|
+
if not selected:
|
|
67
|
+
selected = data.get("default")
|
|
68
|
+
source = str(_profiles_path()) if selected else None
|
|
69
|
+
|
|
70
|
+
if not selected:
|
|
71
|
+
return {"name": None, "source": None, "api_key": None, "api_base": DEFAULT_CODEER_API_BASE}
|
|
72
|
+
|
|
73
|
+
profile = (data.get("profiles") or {}).get(selected)
|
|
74
|
+
if not isinstance(profile, dict):
|
|
75
|
+
return {"name": selected, "source": source, "api_key": None, "api_base": DEFAULT_CODEER_API_BASE}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"name": selected,
|
|
79
|
+
"source": source,
|
|
80
|
+
"api_key": profile.get("api_key"),
|
|
81
|
+
"api_base": profile.get("api_base") or DEFAULT_CODEER_API_BASE,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_current(args, client=None) -> int:
|
|
86
|
+
resolved = resolve_profile()
|
|
87
|
+
name = resolved.get("name") or "(none)"
|
|
88
|
+
source = resolved.get("source") or "(none)"
|
|
89
|
+
api_key = resolved.get("api_key")
|
|
90
|
+
api_base = resolved.get("api_base") or DEFAULT_CODEER_API_BASE
|
|
91
|
+
|
|
92
|
+
print(f"Profile: {name}")
|
|
93
|
+
print(f"Source: {source}")
|
|
94
|
+
print(f"API base: {api_base}")
|
|
95
|
+
print(f"API key: {_mask(api_key) if api_key else '(missing)'}")
|
|
96
|
+
return 0 if api_key else 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_list(args, client=None) -> int:
|
|
100
|
+
data = _read_profiles()
|
|
101
|
+
profiles = data.get("profiles") or {}
|
|
102
|
+
default = data.get("default")
|
|
103
|
+
if not profiles:
|
|
104
|
+
print("No profiles configured")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
for name in sorted(profiles):
|
|
108
|
+
marker = "*" if name == default else " "
|
|
109
|
+
api_base = profiles[name].get("api_base") or DEFAULT_CODEER_API_BASE
|
|
110
|
+
print(f"{marker} {name}\t{api_base}")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_add(args, client=None) -> int:
|
|
115
|
+
api_key = getpass.getpass("API key: ").strip()
|
|
116
|
+
if not api_key:
|
|
117
|
+
print("error: API key is required", file=sys.stderr)
|
|
118
|
+
return 2
|
|
119
|
+
|
|
120
|
+
data = _read_profiles()
|
|
121
|
+
profiles = data.setdefault("profiles", {})
|
|
122
|
+
profiles[args.name] = {"api_key": api_key, "api_base": args.api_base}
|
|
123
|
+
if not data.get("default"):
|
|
124
|
+
data["default"] = args.name
|
|
125
|
+
_write_profiles(data)
|
|
126
|
+
print(f"Saved profile {args.name}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_use(args, client=None) -> int:
|
|
131
|
+
data = _read_profiles()
|
|
132
|
+
if args.name not in (data.get("profiles") or {}):
|
|
133
|
+
print(f"error: profile {args.name!r} does not exist", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
|
|
136
|
+
path = Path.cwd() / CONFIG_DIR / LOCAL_PROFILE_FILE
|
|
137
|
+
path.parent.mkdir(mode=0o700, exist_ok=True)
|
|
138
|
+
path.write_text(args.name + "\n", encoding="utf-8")
|
|
139
|
+
print(f"Using profile {args.name} in {Path.cwd()}")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_default(args, client=None) -> int:
|
|
144
|
+
data = _read_profiles()
|
|
145
|
+
if args.name not in (data.get("profiles") or {}):
|
|
146
|
+
print(f"error: profile {args.name!r} does not exist", file=sys.stderr)
|
|
147
|
+
return 2
|
|
148
|
+
data["default"] = args.name
|
|
149
|
+
_write_profiles(data)
|
|
150
|
+
print(f"Default profile is {args.name}")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_remove(args, client=None) -> int:
|
|
155
|
+
data = _read_profiles()
|
|
156
|
+
profiles = data.get("profiles") or {}
|
|
157
|
+
if args.name not in profiles:
|
|
158
|
+
print(f"error: profile {args.name!r} does not exist", file=sys.stderr)
|
|
159
|
+
return 2
|
|
160
|
+
|
|
161
|
+
del profiles[args.name]
|
|
162
|
+
if data.get("default") == args.name:
|
|
163
|
+
data["default"] = sorted(profiles)[0] if profiles else None
|
|
164
|
+
_write_profiles(data)
|
|
165
|
+
print(f"Removed profile {args.name}")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _profiles_path() -> Path:
|
|
170
|
+
return Path.home() / CONFIG_DIR / GLOBAL_PROFILE_FILE
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _read_profiles() -> dict[str, Any]:
|
|
174
|
+
path = _profiles_path()
|
|
175
|
+
if not path.exists():
|
|
176
|
+
return {"default": None, "profiles": {}}
|
|
177
|
+
with path.open(encoding="utf-8") as f:
|
|
178
|
+
data = json.load(f)
|
|
179
|
+
if not isinstance(data, dict):
|
|
180
|
+
raise ValueError(f"Invalid profile config: {path}")
|
|
181
|
+
data.setdefault("profiles", {})
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _write_profiles(data: dict[str, Any]) -> None:
|
|
186
|
+
path = _profiles_path()
|
|
187
|
+
path.parent.mkdir(mode=0o700, exist_ok=True)
|
|
188
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
189
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _find_local_profile(start: Path) -> Path | None:
|
|
193
|
+
for path in [start, *start.parents]:
|
|
194
|
+
candidate = path / CONFIG_DIR / LOCAL_PROFILE_FILE
|
|
195
|
+
if candidate.exists():
|
|
196
|
+
return candidate
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _mask(value: str | None) -> str:
|
|
201
|
+
if not value:
|
|
202
|
+
return "(missing)"
|
|
203
|
+
if len(value) <= 8:
|
|
204
|
+
return "*" * len(value)
|
|
205
|
+
return value[:4] + "..." + value[-4:]
|
codeer_cli/constants.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Enum values and known identifiers, kept in sync with the backend + frontend.
|
|
2
|
+
|
|
3
|
+
Having these in one place stops callers from inventing strings like ``"text"``
|
|
4
|
+
or ``"select"`` that silently save as broken config. If you're adding a new
|
|
5
|
+
tool type or form field type, update both the backend (``codeer/agents/types.py``,
|
|
6
|
+
``web/src/types/requestForm.ts``) and this file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Unified tool types accepted on an Agent's ``unified_tools[]`` list.
|
|
12
|
+
# Source: codeer/agents/types.py :: UnifiedToolType
|
|
13
|
+
UNIFIED_TOOL_TYPES: frozenset[str] = frozenset({
|
|
14
|
+
"knowledge_base",
|
|
15
|
+
"web_search",
|
|
16
|
+
"call_agent",
|
|
17
|
+
"image_generation",
|
|
18
|
+
"request_form",
|
|
19
|
+
"payment",
|
|
20
|
+
"memory",
|
|
21
|
+
"http_request",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
# Valid type values for a field inside ``custom_form_schema.fields[]``.
|
|
25
|
+
# Source: web/src/types/requestForm.ts :: FormFieldType
|
|
26
|
+
# NOTE: there is no ``"email"`` / ``"text"`` / ``"select"`` type. Common gotchas:
|
|
27
|
+
# email → use "shortText" (plus helpText/placeholder to hint the format)
|
|
28
|
+
# text → use "shortText" (single-line) or "longText" (multi-line)
|
|
29
|
+
# select → use "dropdown" (with ``options: [{value,label}]``)
|
|
30
|
+
FORM_FIELD_TYPES: frozenset[str] = frozenset({
|
|
31
|
+
"shortText",
|
|
32
|
+
"longText",
|
|
33
|
+
"number",
|
|
34
|
+
"dropdown",
|
|
35
|
+
"radio",
|
|
36
|
+
"checkbox",
|
|
37
|
+
"date",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
# An agent's ``publish_state``.
|
|
41
|
+
# Source: codeer/agents/types.py :: PublishState
|
|
42
|
+
PUBLISH_STATES: frozenset[str] = frozenset({
|
|
43
|
+
"private",
|
|
44
|
+
"in_organization",
|
|
45
|
+
"public",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# ``AgentHistory.status`` values returned by ``GET /agents/{id}/histories``.
|
|
49
|
+
# Source: codeer/agents/types.py :: AgentHistoryStatus
|
|
50
|
+
AGENT_HISTORY_STATUSES: frozenset[str] = frozenset({
|
|
51
|
+
"draft",
|
|
52
|
+
"published",
|
|
53
|
+
"archived",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
# Hard limits mirrored from user-docs/agent-creation/tools/index.md. Enforce
|
|
57
|
+
# client-side so we fail before the server rejects.
|
|
58
|
+
MAX_TOOLS_PER_AGENT = 10
|
|
59
|
+
MAX_CALL_AGENT_TOOLS = 5
|
|
60
|
+
MAX_MEMORY_TOOLS = 1
|
|
61
|
+
|
|
62
|
+
# Fields that must be set (and non-empty) on every form-builder field so the UI
|
|
63
|
+
# renders labels and the backend can capture submissions by a stable key.
|
|
64
|
+
# ``name`` is the submission key, ``label`` is the analytics/column name,
|
|
65
|
+
# ``question`` is the user-facing prompt.
|
|
66
|
+
REQUIRED_FORM_FIELD_KEYS: tuple[str, ...] = ("id", "type", "name", "label", "question", "required")
|