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 ADDED
@@ -0,0 +1,54 @@
1
+ """Thin Python client for the Codeer internal API.
2
+
3
+ Import pattern for scripts::
4
+
5
+ from codeer_cli import CodeerClient, agents, kb, eval_, histories, chats
6
+
7
+ c = CodeerClient.from_env()
8
+ agent = agents.create(c, workspace_id=..., name="My Agent", system_prompt="...", unified_tools=[])
9
+
10
+ Per-project env convention: set CODEER_AGENT_ID as an environment variable so
11
+ scripts in a customer's directory don't have to re-pass it.
12
+
13
+ The workspace and organization come from the workspace API-key virtual user's
14
+ profile, not from CLI flags or environment overrides.
15
+
16
+ Auth uses CODEER_API_KEY from process env only. CODEER_API_BASE defaults to
17
+ production and can be overridden from process env. The CLI does not read
18
+ workspace-local dotenv files or credential files.
19
+ """
20
+
21
+ from ._validate import ToolValidationError
22
+ from .client import AuthError, CodeerClient, CodeerError
23
+ from .parse import (
24
+ AgentSummary,
25
+ ConversationTurn,
26
+ EvalResultSummary,
27
+ EvalToolCall,
28
+ HistorySummary,
29
+ KBNode,
30
+ ToolCall,
31
+ parse_agent,
32
+ parse_conversation_turn,
33
+ parse_conversations,
34
+ parse_eval_result,
35
+ parse_eval_tool_calls,
36
+ parse_kb_node,
37
+ parse_kb_nodes,
38
+ parse_rubrics_from_reason,
39
+ parse_tool_calls,
40
+ strip_tool_markers,
41
+ summarize_eval_tool_calls,
42
+ summarize_history,
43
+ )
44
+
45
+ __all__ = [
46
+ "CodeerClient", "CodeerError", "AuthError", "ToolValidationError",
47
+ # parsers
48
+ "AgentSummary", "ConversationTurn", "EvalResultSummary", "HistorySummary",
49
+ "KBNode", "ToolCall", "EvalToolCall",
50
+ "parse_agent", "parse_conversation_turn", "parse_conversations",
51
+ "parse_eval_result", "parse_eval_tool_calls", "parse_kb_node", "parse_kb_nodes",
52
+ "parse_rubrics_from_reason",
53
+ "parse_tool_calls", "strip_tool_markers", "summarize_eval_tool_calls", "summarize_history",
54
+ ]
@@ -0,0 +1,131 @@
1
+ """Client-side validation for unified-tool payloads.
2
+
3
+ These checks exist because the backend's form-schema validator is lenient
4
+ (``extra="allow"``) and silently accepts unknown ``type`` strings, which then
5
+ render as blank fields in the web builder. Catching the common mistakes here
6
+ gives actionable errors before the PUT/POST round-trip.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Iterable
12
+
13
+ from .constants import (
14
+ FORM_FIELD_TYPES,
15
+ MAX_CALL_AGENT_TOOLS,
16
+ MAX_MEMORY_TOOLS,
17
+ MAX_TOOLS_PER_AGENT,
18
+ REQUIRED_FORM_FIELD_KEYS,
19
+ UNIFIED_TOOL_TYPES,
20
+ )
21
+
22
+
23
+ class ToolValidationError(ValueError):
24
+ """Raised when a unified_tools payload is definitely wrong."""
25
+
26
+
27
+ def validate_unified_tools(tools: Iterable[dict[str, Any]] | None) -> list[dict[str, Any]]:
28
+ """Raise ToolValidationError on invalid payloads; otherwise return the list.
29
+
30
+ Validates type strings against the backend enum and walks into
31
+ ``custom_form_schema.fields`` for request_form tools so we catch the
32
+ ``type: "text" | "email" | "select"`` mistake the UI renders as blank.
33
+ """
34
+ tools_list = list(tools or [])
35
+ if len(tools_list) > MAX_TOOLS_PER_AGENT:
36
+ raise ToolValidationError(
37
+ f"{len(tools_list)} tools configured, max allowed is {MAX_TOOLS_PER_AGENT} "
38
+ "(see user-docs/agent-creation/tools/index.md)."
39
+ )
40
+
41
+ call_agent_count = sum(1 for t in tools_list if t.get("type") == "call_agent")
42
+ if call_agent_count > MAX_CALL_AGENT_TOOLS:
43
+ raise ToolValidationError(
44
+ f"{call_agent_count} call_agent tools configured, max is {MAX_CALL_AGENT_TOOLS}."
45
+ )
46
+
47
+ memory_count = sum(1 for t in tools_list if t.get("type") == "memory")
48
+ if memory_count > MAX_MEMORY_TOOLS:
49
+ raise ToolValidationError(
50
+ f"{memory_count} memory tools configured, max is {MAX_MEMORY_TOOLS}."
51
+ )
52
+
53
+ for i, tool in enumerate(tools_list):
54
+ _validate_single_tool(tool, i)
55
+
56
+ return tools_list
57
+
58
+
59
+ def _validate_single_tool(tool: dict[str, Any], index: int) -> None:
60
+ prefix = f"unified_tools[{index}]"
61
+ tool_type = tool.get("type")
62
+ if tool_type not in UNIFIED_TOOL_TYPES:
63
+ raise ToolValidationError(
64
+ f"{prefix}.type = {tool_type!r} is not a valid UnifiedToolType. "
65
+ f"Valid values: {sorted(UNIFIED_TOOL_TYPES)}."
66
+ )
67
+ if not tool.get("id"):
68
+ raise ToolValidationError(f"{prefix}.id is required and must be non-empty.")
69
+
70
+ if tool_type == "request_form":
71
+ _validate_form_schema(tool.get("custom_form_schema"), prefix)
72
+ elif tool_type == "knowledge_base":
73
+ node_ids = tool.get("knowledge_node_ids")
74
+ if node_ids is None or (isinstance(node_ids, list) and not node_ids):
75
+ raise ToolValidationError(
76
+ f"{prefix}: knowledge_base tool needs non-empty knowledge_node_ids."
77
+ )
78
+ elif tool_type == "call_agent":
79
+ if not tool.get("agent_id"):
80
+ raise ToolValidationError(f"{prefix}: call_agent tool requires agent_id.")
81
+ elif tool_type == "http_request":
82
+ cfg = tool.get("http_request")
83
+ if not cfg or not cfg.get("method") or not cfg.get("url_template"):
84
+ raise ToolValidationError(
85
+ f"{prefix}: http_request tool requires http_request.method and http_request.url_template."
86
+ )
87
+
88
+
89
+ def _validate_form_schema(schema: Any, prefix: str) -> None:
90
+ if schema is None:
91
+ raise ToolValidationError(
92
+ f"{prefix}: request_form tool requires custom_form_schema "
93
+ "(with title, fields[], ...)."
94
+ )
95
+ if not isinstance(schema, dict):
96
+ raise ToolValidationError(f"{prefix}.custom_form_schema must be an object.")
97
+ fields = schema.get("fields")
98
+ if not isinstance(fields, list) or not fields:
99
+ raise ToolValidationError(
100
+ f"{prefix}.custom_form_schema.fields must be a non-empty list."
101
+ )
102
+
103
+ for j, field in enumerate(fields):
104
+ fp = f"{prefix}.custom_form_schema.fields[{j}]"
105
+ if not isinstance(field, dict):
106
+ raise ToolValidationError(f"{fp} must be an object.")
107
+ for key in REQUIRED_FORM_FIELD_KEYS:
108
+ if key not in field or field[key] in (None, ""):
109
+ raise ToolValidationError(
110
+ f"{fp}.{key} is required and must be non-empty."
111
+ )
112
+ ftype = field.get("type")
113
+ if ftype not in FORM_FIELD_TYPES:
114
+ # The two traps we already hit — surface a hint, not just a rejection.
115
+ hint = ""
116
+ if ftype == "text":
117
+ hint = " (use 'shortText' for single-line or 'longText' for multi-line)"
118
+ elif ftype == "email":
119
+ hint = " (there is no 'email' type — use 'shortText' and add placeholder/helpText)"
120
+ elif ftype == "select":
121
+ hint = " (use 'dropdown' with options=[{value,label}])"
122
+ raise ToolValidationError(
123
+ f"{fp}.type = {ftype!r} is not a valid FormFieldType. "
124
+ f"Valid: {sorted(FORM_FIELD_TYPES)}.{hint}"
125
+ )
126
+ if ftype == "dropdown" or ftype == "radio":
127
+ options = field.get("options")
128
+ if not isinstance(options, list) or not options:
129
+ raise ToolValidationError(
130
+ f"{fp}: type={ftype!r} requires non-empty options=[{{value,label}}]."
131
+ )
codeer_cli/agents.py ADDED
@@ -0,0 +1,155 @@
1
+ """Agent CRUD, versioning, and publishing.
2
+
3
+ Each function returns the parsed `data` field from the Ninja response envelope.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, List, Optional
9
+
10
+ from ._validate import validate_unified_tools
11
+ from .client import CodeerClient
12
+
13
+
14
+ def create(
15
+ client: CodeerClient,
16
+ *,
17
+ workspace_id: str,
18
+ name: str,
19
+ system_prompt: str,
20
+ unified_tools: Optional[List[dict]] = None,
21
+ use_search: bool = False,
22
+ llm_model: Optional[str] = None,
23
+ description: Optional[str] = None,
24
+ suggested_questions: Optional[List[str]] = None,
25
+ primary_object_ids: Optional[List[int]] = None,
26
+ attachment_ids: Optional[List[str]] = None,
27
+ ) -> dict:
28
+ validated_tools = validate_unified_tools(unified_tools)
29
+ body: dict[str, Any] = {
30
+ "name": name,
31
+ "system_prompt": system_prompt,
32
+ "unified_tools": validated_tools,
33
+ "primary_object_ids": primary_object_ids or [],
34
+ "attachment_ids": attachment_ids or [],
35
+ "use_search": use_search,
36
+ "suggested_questions": suggested_questions or [],
37
+ }
38
+ if description is not None:
39
+ body["description"] = description
40
+ if llm_model is not None:
41
+ body["llm_model"] = llm_model
42
+ return client.post("/external/agents", json=body)
43
+
44
+
45
+ def update(
46
+ client: CodeerClient,
47
+ agent_id: str,
48
+ *,
49
+ name: str,
50
+ system_prompt: str,
51
+ unified_tools: List[dict],
52
+ use_search: bool,
53
+ version_note: str = "",
54
+ description: Optional[str] = None,
55
+ llm_model: Optional[str] = None,
56
+ suggested_questions: Optional[List[str]] = None,
57
+ primary_object_ids: Optional[List[int]] = None,
58
+ attachment_ids: Optional[List[str]] = None,
59
+ ) -> dict:
60
+ """PUT creates a new AgentHistory snapshot (draft)."""
61
+ validated_tools = validate_unified_tools(unified_tools)
62
+ body: dict[str, Any] = {
63
+ "name": name,
64
+ "system_prompt": system_prompt,
65
+ "unified_tools": validated_tools,
66
+ "primary_object_ids": primary_object_ids or [],
67
+ "attachment_ids": attachment_ids or [],
68
+ "use_search": use_search,
69
+ "version_note": version_note,
70
+ "suggested_questions": suggested_questions or [],
71
+ }
72
+ if description is not None:
73
+ body["description"] = description
74
+ if llm_model is not None:
75
+ body["llm_model"] = llm_model
76
+ return client.patch(f"/external/agents/{agent_id}", json=body)
77
+
78
+
79
+ def get(client: CodeerClient, agent_id: str) -> dict:
80
+ return client.get(f"/external/agents/{agent_id}")
81
+
82
+
83
+ def get_default(client: CodeerClient) -> dict:
84
+ """Read whichever agent ``CODEER_AGENT_ID`` points to (project env).
85
+
86
+ Workspace and organization scope come from the API-key virtual user's
87
+ profile. Raises if no ``agent_id`` is in scope.
88
+ """
89
+ if not client.agent_id:
90
+ raise ValueError(
91
+ "No agent_id in scope. Set CODEER_AGENT_ID in .claude/settings.json, "
92
+ "export it in your shell, or pass agent_id explicitly to from_env()."
93
+ )
94
+ return get(client, client.agent_id)
95
+
96
+
97
+ def get_latest_draft_history_id(client: CodeerClient, agent_id: str) -> Optional[str]:
98
+ """Return the id of the most recent unpublished AgentHistory, or None.
99
+
100
+ "Most recent" = highest ``version_number`` among ``status == 'draft'``.
101
+ Returns ``None`` if every version has been published (so there's no
102
+ open draft to pin a test against).
103
+ """
104
+ versions = list_versions(client, agent_id)
105
+ drafts = [v for v in versions if v.get("status") == "draft"]
106
+ if not drafts:
107
+ return None
108
+ drafts.sort(key=lambda v: v.get("version_number") or 0, reverse=True)
109
+ return drafts[0].get("id")
110
+
111
+
112
+ def get_latest_history_id(client: CodeerClient, agent_id: str) -> Optional[str]:
113
+ """Return the id of the most recent AgentHistory, draft or published."""
114
+ versions = list_versions(client, agent_id)
115
+ if not versions:
116
+ return None
117
+ versions.sort(key=lambda v: v.get("version_number") or 0, reverse=True)
118
+ return versions[0].get("id")
119
+
120
+
121
+ def list_in_workspace(client: CodeerClient, workspace_id: str) -> list[dict]:
122
+ """List **published** agents in a workspace (drafts are hidden).
123
+
124
+ If you need drafts too — which is almost always the case while iterating
125
+ on an agent — use :func:`list_all` with both workspace_id and organization_id.
126
+ """
127
+ return client.get("/external/agents")
128
+
129
+
130
+ def list_all(
131
+ client: CodeerClient,
132
+ *,
133
+ workspace_id: str,
134
+ organization_id: str,
135
+ ) -> list[dict]:
136
+ """List every agent in a workspace including drafts.
137
+
138
+ Both IDs are required — ``GET /agents/all`` returns 400 ``Organization ID
139
+ is required`` if you omit ``oid``. Look up the org for a workspace via
140
+ ``/accounts/me`` → ``profile.workspace_organization_map``.
141
+ """
142
+ return client.get("/external/agents/all")
143
+
144
+
145
+ def list_versions(client: CodeerClient, agent_id: str) -> list[dict]:
146
+ return client.get(f"/external/agents/{agent_id}/versions")
147
+
148
+
149
+ def get_version(client: CodeerClient, agent_id: str, history_id: str) -> dict:
150
+ return client.get(f"/external/agents/{agent_id}/versions/{history_id}")
151
+
152
+
153
+ def check_impact(client: CodeerClient, agent_id: str) -> dict:
154
+ """List downstream agents that call this one. Call before publishing breaking changes."""
155
+ return client.get(f"/external/agents/{agent_id}/impact")
codeer_cli/chats.py ADDED
@@ -0,0 +1,87 @@
1
+ """Chat creation and SSE-streamed agent responses — the Live Test surface.
2
+
3
+ The send_message() path is the main dogfood driver: open a chat against a
4
+ specific agent version (unpublished draft is fine) and consume the SSE stream
5
+ to get tool calls, reasoning, and final text back.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Iterator, List, Optional
11
+
12
+ from .client import CodeerClient
13
+
14
+
15
+ def create(
16
+ client: CodeerClient,
17
+ *,
18
+ agent_id: str,
19
+ title: Optional[str] = None,
20
+ external_user_id: Optional[str] = None,
21
+ ) -> dict:
22
+ body: dict[str, Any] = {"agent_id": agent_id}
23
+ if title is not None:
24
+ body["name"] = title
25
+ if external_user_id is not None:
26
+ body["external_user_id"] = external_user_id
27
+ return client.post("/chats", json=body)
28
+
29
+
30
+ def send_published_agent_message(
31
+ client: CodeerClient,
32
+ *,
33
+ chat_id: int,
34
+ message: str,
35
+ agent_id: str,
36
+ external_user_id: Optional[str] = None,
37
+ attachment_ids: Optional[List[str]] = None,
38
+ stream: bool = False,
39
+ ) -> Iterator[dict] | dict:
40
+ """Send a user message through the API-key external chat flow.
41
+
42
+ API-key chat endpoints use the agent's published version. They accept
43
+ ``agent_id`` rather than ``agent_history_id``.
44
+ """
45
+ body: dict[str, Any] = {"message": message, "agent_id": agent_id, "stream": stream}
46
+ if external_user_id is not None:
47
+ body["external_user_id"] = external_user_id
48
+ if attachment_ids:
49
+ body["attached_file_uuids"] = attachment_ids
50
+
51
+ path = f"/chats/{chat_id}/messages"
52
+ if stream:
53
+ return client.stream_sse("POST", path, json=body)
54
+ return client.post(path, json=body)
55
+
56
+
57
+ def send_message(
58
+ client: CodeerClient,
59
+ *,
60
+ chat_id: int,
61
+ message: str,
62
+ agent_history_id: str,
63
+ attachment_ids: Optional[List[str]] = None,
64
+ stream: bool = True,
65
+ ) -> Iterator[dict] | dict:
66
+ """Send a user message; yield SSE events (if stream=True) or return the final payload.
67
+
68
+ ``agent_history_id`` is required — this is how you pin Live Test to a specific
69
+ (possibly unpublished) agent version without affecting production users.
70
+ """
71
+ body: dict[str, Any] = {"message": message, "agent_history_id": agent_history_id}
72
+ if attachment_ids:
73
+ body["attachment_ids"] = attachment_ids
74
+
75
+ path = f"/chats/{chat_id}/messages"
76
+ if stream:
77
+ return client.stream_sse("POST", path, json=body)
78
+ return client.post(path, json=body)
79
+
80
+
81
+ def list_messages(client: CodeerClient, chat_id: int) -> list[dict]:
82
+ return client.get(f"/chats/{chat_id}/messages")
83
+
84
+
85
+ def list_chats(client: CodeerClient) -> list[dict]:
86
+ return client.get("/chats")
87
+
codeer_cli/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ """Codeer CLI — unified interface for agent lifecycle operations.
2
+
3
+ codeer check
4
+ codeer agent list|get|apply|diff|versions
5
+ codeer kb list|files|upload
6
+ codeer eval list|evaluators|evaluator-create|evaluator-update|run|export|reconcile|cases-apply|rubrics|rubrics-apply
7
+ codeer history list|get|conversations|negative-feedback
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ from .client import AuthError, CodeerClient, CodeerError
17
+ from .commands import check
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(prog="codeer")
22
+ sub = parser.add_subparsers(dest="group")
23
+
24
+ check.register(sub)
25
+
26
+ # Phase 2-4: agent, kb, eval commands will register here
27
+ try:
28
+ from .commands import agent as agent_cmd
29
+ agent_cmd.register(sub)
30
+ except ImportError:
31
+ pass
32
+
33
+ try:
34
+ from .commands import kb as kb_cmd
35
+ kb_cmd.register(sub)
36
+ except ImportError:
37
+ pass
38
+
39
+ try:
40
+ from .commands import eval_cmd
41
+ eval_cmd.register(sub)
42
+ except ImportError:
43
+ pass
44
+
45
+ try:
46
+ from .commands import history as history_cmd
47
+ history_cmd.register(sub)
48
+ except ImportError:
49
+ pass
50
+
51
+ try:
52
+ from .commands import profile as profile_cmd
53
+ profile_cmd.register(sub)
54
+ except ImportError:
55
+ pass
56
+
57
+ args = parser.parse_args(argv)
58
+
59
+ if not hasattr(args, "func"):
60
+ parser.print_help()
61
+ return 2
62
+
63
+ if getattr(args, "no_client", False):
64
+ client = None
65
+ else:
66
+ try:
67
+ client = CodeerClient.from_env()
68
+ except AuthError as e:
69
+ if args.group == "check":
70
+ print(f"FAIL Auth: {e}", file=sys.stderr)
71
+ print(" Configure CODEER_API_KEY or a codeer profile", file=sys.stderr)
72
+ return 1
73
+ print(f"auth error: {e}", file=sys.stderr)
74
+ return 2
75
+
76
+ try:
77
+ return args.func(args, client)
78
+ except AuthError as e:
79
+ print(f"auth: {e}", file=sys.stderr)
80
+ return 3
81
+ except CodeerError as e:
82
+ print(f"error: {e}", file=sys.stderr)
83
+ if e.body:
84
+ print(json.dumps(e.body, ensure_ascii=False, indent=2, default=str), file=sys.stderr)
85
+ return 1
86
+ finally:
87
+ if client is not None:
88
+ client.close()
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())