sanityops-cli 0.1.3__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.
- sanityops_cli/__init__.py +16 -0
- sanityops_cli/agents/__init__.py +14 -0
- sanityops_cli/agents/repair_agent/__init__.py +19 -0
- sanityops_cli/agents/repair_agent/agent.py +230 -0
- sanityops_cli/agents/repair_agent/prompts.py +100 -0
- sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
- sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
- sanityops_cli/agents/scanner_agent/agent.py +332 -0
- sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
- sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
- sanityops_cli/agents/scanner_agent/prompts.py +316 -0
- sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
- sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
- sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
- sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
- sanityops_cli/api/__init__.py +16 -0
- sanityops_cli/api/client.py +403 -0
- sanityops_cli/commands/__init__.py +14 -0
- sanityops_cli/commands/config.py +312 -0
- sanityops_cli/commands/init.py +132 -0
- sanityops_cli/commands/inspect.py +646 -0
- sanityops_cli/constants/__init__.py +14 -0
- sanityops_cli/constants/config_defaults.py +22 -0
- sanityops_cli/constants/exit_codes.py +19 -0
- sanityops_cli/defect_checker/__init__.py +16 -0
- sanityops_cli/defect_checker/checker.py +100 -0
- sanityops_cli/defect_checker/llm_config.py +112 -0
- sanityops_cli/defect_checker/markdown_reporter.py +249 -0
- sanityops_cli/defect_checker/renderer.py +203 -0
- sanityops_cli/exceptions/__init__.py +14 -0
- sanityops_cli/exceptions/api_exceptions.py +60 -0
- sanityops_cli/exceptions/base_exceptions.py +25 -0
- sanityops_cli/help_panel.py +49 -0
- sanityops_cli/logging/__init__.py +18 -0
- sanityops_cli/logging/logger.py +108 -0
- sanityops_cli/main.py +123 -0
- sanityops_cli/progress/__init__.py +18 -0
- sanityops_cli/progress/tracker.py +159 -0
- sanityops_cli/renderers/__init__.py +14 -0
- sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
- sanityops_cli/templates/__init__.py +14 -0
- sanityops_cli/templates/inspect_config.yaml +55 -0
- sanityops_cli/utils/__init__.py +14 -0
- sanityops_cli/utils/artifact_packer.py +407 -0
- sanityops_cli/utils/config_loader.py +296 -0
- sanityops_cli/utils/config_resolver.py +358 -0
- sanityops_cli/utils/validators.py +117 -0
- sanityops_cli-0.1.3.dist-info/METADATA +213 -0
- sanityops_cli-0.1.3.dist-info/RECORD +53 -0
- sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
- sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
- sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
- sanityops_cli-0.1.3.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import ClassVar
|
|
19
|
+
|
|
20
|
+
from sanityops_agent.tools.base import Tool, ToolResult
|
|
21
|
+
|
|
22
|
+
from ..models.finding import Finding, FindingType
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StoreFindingsTool(Tool):
|
|
26
|
+
"""
|
|
27
|
+
maintain a shared memory list of findings (skills/tools/prompts) and export to JSON file.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name = "store_findings"
|
|
31
|
+
description = "collect and store skills/tools/prompts"
|
|
32
|
+
parameters = {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"operation": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"enum": ["add", "flush", "clear", "count"],
|
|
38
|
+
"description": "operation to perform: add (add a finding), flush (export to JSON), clear (clear memory), count (count findings)",
|
|
39
|
+
},
|
|
40
|
+
"finding": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"description": "the finding object to add (required for add operation)",
|
|
43
|
+
"properties": {
|
|
44
|
+
"type": {"type": "string", "enum": ["skill", "tool", "prompt"]},
|
|
45
|
+
"relative": {"type": "string", "description": "absolute path"},
|
|
46
|
+
"content": {"type": "object", "description": "optional content object"},
|
|
47
|
+
},
|
|
48
|
+
"required": ["type", "relative"],
|
|
49
|
+
},
|
|
50
|
+
"output_path": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "path to export JSON file (required for flush operation)",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"required": ["operation"],
|
|
56
|
+
}
|
|
57
|
+
tags = ["memory"]
|
|
58
|
+
|
|
59
|
+
_findings: ClassVar[list[Finding]] = []
|
|
60
|
+
|
|
61
|
+
async def execute(
|
|
62
|
+
self,
|
|
63
|
+
operation: str,
|
|
64
|
+
finding: Finding | None = None,
|
|
65
|
+
output_path: str | None = None,
|
|
66
|
+
) -> ToolResult:
|
|
67
|
+
if operation == "add":
|
|
68
|
+
return self._add_finding(finding)
|
|
69
|
+
elif operation == "flush":
|
|
70
|
+
return self._flush(output_path)
|
|
71
|
+
elif operation == "clear":
|
|
72
|
+
return self._clear()
|
|
73
|
+
elif operation == "count":
|
|
74
|
+
return self._count()
|
|
75
|
+
else:
|
|
76
|
+
return ToolResult(
|
|
77
|
+
content="",
|
|
78
|
+
success=False,
|
|
79
|
+
error=f"unknown operation: {operation}",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _add_finding(self, finding_dict: Finding | None) -> ToolResult:
|
|
83
|
+
if finding_dict is None:
|
|
84
|
+
return ToolResult(
|
|
85
|
+
content="",
|
|
86
|
+
success=False,
|
|
87
|
+
error="missing finding parameter",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
finding = Finding.model_validate(finding_dict)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
return ToolResult(
|
|
94
|
+
content="",
|
|
95
|
+
success=False,
|
|
96
|
+
error=f"Finding validation failed: {e}",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
self._findings.append(finding)
|
|
100
|
+
return ToolResult(
|
|
101
|
+
content=json.dumps({
|
|
102
|
+
"success": True,
|
|
103
|
+
"message": f"added {finding.type.value}: {finding.relative}",
|
|
104
|
+
"count": len(self._findings),
|
|
105
|
+
}),
|
|
106
|
+
success=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _flush(self, output_path: str | None) -> ToolResult:
|
|
110
|
+
if not output_path:
|
|
111
|
+
return ToolResult(
|
|
112
|
+
content="",
|
|
113
|
+
success=False,
|
|
114
|
+
error="missing output_path parameter",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
result = {
|
|
118
|
+
"skills": [
|
|
119
|
+
f.model_dump()
|
|
120
|
+
for f in self._findings
|
|
121
|
+
if f.type == FindingType.SKILL
|
|
122
|
+
],
|
|
123
|
+
"tools": [
|
|
124
|
+
f.model_dump()
|
|
125
|
+
for f in self._findings
|
|
126
|
+
if f.type == FindingType.TOOL
|
|
127
|
+
],
|
|
128
|
+
"prompts": [
|
|
129
|
+
f.model_dump()
|
|
130
|
+
for f in self._findings
|
|
131
|
+
if f.type == FindingType.PROMPT
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
path = Path(output_path)
|
|
136
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
path.write_text(
|
|
138
|
+
json.dumps(result, indent=2, ensure_ascii=False),
|
|
139
|
+
encoding="utf-8"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return ToolResult(
|
|
143
|
+
content=json.dumps({
|
|
144
|
+
"success": True,
|
|
145
|
+
"message": f"exported {len(self._findings)} findings to {output_path}",
|
|
146
|
+
"counts": {
|
|
147
|
+
"skills": len(result["skills"]),
|
|
148
|
+
"tools": len(result["tools"]),
|
|
149
|
+
"prompts": len(result["prompts"]),
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
success=True,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _clear(self) -> ToolResult:
|
|
156
|
+
count = len(self._findings)
|
|
157
|
+
self._findings.clear()
|
|
158
|
+
return ToolResult(
|
|
159
|
+
content=json.dumps({
|
|
160
|
+
"success": True,
|
|
161
|
+
"message": f"cleared {count} findings",
|
|
162
|
+
}),
|
|
163
|
+
success=True,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _count(self) -> ToolResult:
|
|
167
|
+
return ToolResult(
|
|
168
|
+
content=json.dumps({
|
|
169
|
+
"success": True,
|
|
170
|
+
"count": len(self._findings),
|
|
171
|
+
"by_type": {
|
|
172
|
+
"skills": sum(1 for f in self._findings if f.type == FindingType.SKILL),
|
|
173
|
+
"tools": sum(1 for f in self._findings if f.type == FindingType.TOOL),
|
|
174
|
+
"prompts": sum(1 for f in self._findings if f.type == FindingType.PROMPT),
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
success=True,
|
|
178
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
"""Sanityops API client package."""
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
"""HTTP client for sanityops SaaS API."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from sanityops_cli.exceptions.api_exceptions import (
|
|
26
|
+
APIError,
|
|
27
|
+
APIValidationError,
|
|
28
|
+
AuthenticationError,
|
|
29
|
+
NetworkError,
|
|
30
|
+
ProjectNotFoundError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SanityopsClient:
|
|
35
|
+
"""HTTP client for sanityops SaaS API.
|
|
36
|
+
|
|
37
|
+
Handles authentication, request formatting, and error handling for all
|
|
38
|
+
sanityops API operations.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
base_url: The base URL for the API (e.g., "https://api.sanityops.org").
|
|
42
|
+
api_key: The API key for Bearer token authentication.
|
|
43
|
+
timeout: Request timeout in seconds.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
base_url: str,
|
|
49
|
+
api_key: str,
|
|
50
|
+
timeout: float = 30.0,
|
|
51
|
+
):
|
|
52
|
+
"""Initialize the API client.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
base_url: Base URL for the API (without trailing slash).
|
|
56
|
+
api_key: API key for authentication (will be prefixed with "Bearer").
|
|
57
|
+
timeout: Request timeout in seconds.
|
|
58
|
+
"""
|
|
59
|
+
self.base_url = base_url.rstrip("/")
|
|
60
|
+
self.api_key = api_key
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
|
|
63
|
+
def _get_headers(self) -> dict[str, str]:
|
|
64
|
+
"""Get common headers for API requests."""
|
|
65
|
+
return {
|
|
66
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def _unwrap_response(self, response_data: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
+
"""Unwrap the standard API response envelope.
|
|
72
|
+
|
|
73
|
+
The server returns responses in format: {code, data, message}
|
|
74
|
+
This method extracts the 'data' field for easier consumption.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
response_data: Raw response from the API.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The 'data' field if present, otherwise the raw response.
|
|
81
|
+
"""
|
|
82
|
+
if "data" in response_data and "code" in response_data:
|
|
83
|
+
return response_data["data"] or {}
|
|
84
|
+
return response_data
|
|
85
|
+
|
|
86
|
+
def _handle_error(self, response: httpx.Response, operation: str) -> None:
|
|
87
|
+
"""Handle HTTP error responses and raise appropriate exceptions.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
response: The HTTP response object.
|
|
91
|
+
operation: Description of the operation for error messages.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
AuthenticationError: For 401/403 responses.
|
|
95
|
+
ProjectNotFoundError: For 404 responses.
|
|
96
|
+
APIValidationError: For 422 responses.
|
|
97
|
+
APIError: For other HTTP errors.
|
|
98
|
+
"""
|
|
99
|
+
status_code = response.status_code
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
error_data = response.json()
|
|
103
|
+
detail = error_data.get("detail", response.text)
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
detail = response.text
|
|
106
|
+
|
|
107
|
+
if status_code in (401, 403):
|
|
108
|
+
raise AuthenticationError(
|
|
109
|
+
f"Authentication failed during {operation}. "
|
|
110
|
+
"Please check your API key is valid and not expired."
|
|
111
|
+
)
|
|
112
|
+
elif status_code == 404:
|
|
113
|
+
raise ProjectNotFoundError(f"Resource not found during {operation}: {detail}")
|
|
114
|
+
elif status_code == 422:
|
|
115
|
+
raise APIValidationError(f"Validation error during {operation}: {detail}")
|
|
116
|
+
else:
|
|
117
|
+
raise APIError(f"API error during {operation} (HTTP {status_code}): {detail}")
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------
|
|
120
|
+
# Project Operations
|
|
121
|
+
# ---------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def create_project(
|
|
124
|
+
self,
|
|
125
|
+
name: str,
|
|
126
|
+
description: str | None = None,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Create a new project.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
name: Project name (required, 1-200 chars).
|
|
132
|
+
description: Optional project description.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
API response with project details including 'project_id'.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
AuthenticationError: If API key is invalid.
|
|
139
|
+
APIValidationError: If request validation fails.
|
|
140
|
+
NetworkError: If connection fails.
|
|
141
|
+
"""
|
|
142
|
+
url = f"{self.base_url}/api/v1/projects/"
|
|
143
|
+
payload: dict[str, Any] = {"name": name}
|
|
144
|
+
if description:
|
|
145
|
+
payload["description"] = description
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
response = httpx.post(
|
|
149
|
+
url,
|
|
150
|
+
json=payload,
|
|
151
|
+
headers=self._get_headers(),
|
|
152
|
+
timeout=self.timeout,
|
|
153
|
+
)
|
|
154
|
+
except httpx.ConnectError as e:
|
|
155
|
+
raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
|
|
156
|
+
except httpx.TimeoutException as e:
|
|
157
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
158
|
+
|
|
159
|
+
if response.status_code not in (200, 201):
|
|
160
|
+
self._handle_error(response, "project creation")
|
|
161
|
+
|
|
162
|
+
return self._unwrap_response(response.json())
|
|
163
|
+
|
|
164
|
+
def get_project(
|
|
165
|
+
self,
|
|
166
|
+
project_id: str,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Get a single project's details.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
project_id: Project UUID.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
API response with project details including 'name' and 'project_id'.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
AuthenticationError: If API key is invalid.
|
|
178
|
+
ProjectNotFoundError: If project doesn't exist.
|
|
179
|
+
NetworkError: If connection fails.
|
|
180
|
+
"""
|
|
181
|
+
url = f"{self.base_url}/api/v1/projects/{project_id}"
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
response = httpx.get(
|
|
185
|
+
url,
|
|
186
|
+
headers=self._get_headers(),
|
|
187
|
+
timeout=self.timeout,
|
|
188
|
+
)
|
|
189
|
+
except httpx.ConnectError as e:
|
|
190
|
+
raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
|
|
191
|
+
except httpx.TimeoutException as e:
|
|
192
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
193
|
+
|
|
194
|
+
if response.status_code != 200:
|
|
195
|
+
self._handle_error(response, "getting project")
|
|
196
|
+
|
|
197
|
+
return self._unwrap_response(response.json())
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------
|
|
200
|
+
# Artifact Upload
|
|
201
|
+
# ---------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def upload_artifacts(
|
|
204
|
+
self,
|
|
205
|
+
project_id: str | None = None,
|
|
206
|
+
name: str | None = None,
|
|
207
|
+
description: str | None = None,
|
|
208
|
+
prompt_content: str | None = None,
|
|
209
|
+
tools_schema: str | None = None,
|
|
210
|
+
skill_file: tuple[str, bytes] | None = None,
|
|
211
|
+
message: str | None = None,
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""Upload artifacts and create/update a project with automatic version management.
|
|
214
|
+
|
|
215
|
+
If project_id is NOT provided: creates a new project with the uploaded artifacts.
|
|
216
|
+
If project_id IS provided: checks if artifacts have changed, creates new versions
|
|
217
|
+
for changed artifacts, reuses existing versions for unchanged artifacts.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
project_id: Existing project ID (for updates). If None, creates new project.
|
|
221
|
+
name: Project name (required for new project).
|
|
222
|
+
description: Project description.
|
|
223
|
+
prompt_content: Merged prompt text content.
|
|
224
|
+
tools_schema: JSON string of tools schema.
|
|
225
|
+
skill_file: Tuple of (filename, bytes) for ZIP file.
|
|
226
|
+
message: Version commit message.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
API response with project and version details.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
AuthenticationError: If API key is invalid.
|
|
233
|
+
APIValidationError: If request validation fails.
|
|
234
|
+
NetworkError: If connection fails.
|
|
235
|
+
"""
|
|
236
|
+
url = f"{self.base_url}/api/v1/projects/upload"
|
|
237
|
+
|
|
238
|
+
# Build multipart form data
|
|
239
|
+
data: dict[str, Any] = {}
|
|
240
|
+
if project_id:
|
|
241
|
+
data["project_id"] = project_id
|
|
242
|
+
if name:
|
|
243
|
+
data["name"] = name
|
|
244
|
+
if description:
|
|
245
|
+
data["description"] = description
|
|
246
|
+
if prompt_content:
|
|
247
|
+
data["prompt_content"] = prompt_content
|
|
248
|
+
if tools_schema:
|
|
249
|
+
data["tools_schema"] = tools_schema
|
|
250
|
+
if message:
|
|
251
|
+
data["message"] = message
|
|
252
|
+
|
|
253
|
+
files: dict[str, tuple[str, bytes, str]] | None = None
|
|
254
|
+
if skill_file:
|
|
255
|
+
filename, content = skill_file
|
|
256
|
+
files = {"skill_file": (filename, content, "application/zip")}
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
response = httpx.post(
|
|
260
|
+
url,
|
|
261
|
+
data=data,
|
|
262
|
+
files=files,
|
|
263
|
+
headers=self._get_headers(),
|
|
264
|
+
timeout=self.timeout,
|
|
265
|
+
)
|
|
266
|
+
except httpx.ConnectError as e:
|
|
267
|
+
raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
|
|
268
|
+
except httpx.TimeoutException as e:
|
|
269
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
270
|
+
|
|
271
|
+
if response.status_code not in (200, 201):
|
|
272
|
+
self._handle_error(response, "artifact upload")
|
|
273
|
+
|
|
274
|
+
return self._unwrap_response(response.json())
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------
|
|
277
|
+
# Version Operations
|
|
278
|
+
# ---------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def list_versions(
|
|
281
|
+
self,
|
|
282
|
+
project_id: str,
|
|
283
|
+
page: int = 1,
|
|
284
|
+
size: int = 10,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
"""List versions for a project.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
project_id: Project UUID.
|
|
290
|
+
page: Page number (1-indexed).
|
|
291
|
+
size: Page size (1-100).
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
API response with version list.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
AuthenticationError: If API key is invalid.
|
|
298
|
+
ProjectNotFoundError: If project doesn't exist.
|
|
299
|
+
NetworkError: If connection fails.
|
|
300
|
+
"""
|
|
301
|
+
url = f"{self.base_url}/api/v1/projects/{project_id}/versions"
|
|
302
|
+
params = {"page": page, "size": min(size, 100)}
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
response = httpx.get(
|
|
306
|
+
url,
|
|
307
|
+
params=params,
|
|
308
|
+
headers=self._get_headers(),
|
|
309
|
+
timeout=self.timeout,
|
|
310
|
+
)
|
|
311
|
+
except httpx.ConnectError as e:
|
|
312
|
+
raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
|
|
313
|
+
except httpx.TimeoutException as e:
|
|
314
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
315
|
+
|
|
316
|
+
if response.status_code != 200:
|
|
317
|
+
self._handle_error(response, "listing versions")
|
|
318
|
+
|
|
319
|
+
return self._unwrap_response(response.json())
|
|
320
|
+
|
|
321
|
+
def get_version(
|
|
322
|
+
self,
|
|
323
|
+
project_id: str,
|
|
324
|
+
version_id: str,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""Get a specific project version with its artifacts.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
project_id: Project UUID.
|
|
330
|
+
version_id: Version UUID.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
API response with version details including artifacts.
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
AuthenticationError: If API key is invalid.
|
|
337
|
+
ProjectNotFoundError: If project or version doesn't exist.
|
|
338
|
+
NetworkError: If connection fails.
|
|
339
|
+
"""
|
|
340
|
+
url = f"{self.base_url}/api/v1/projects/{project_id}/versions/{version_id}"
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
response = httpx.get(
|
|
344
|
+
url,
|
|
345
|
+
headers=self._get_headers(),
|
|
346
|
+
timeout=self.timeout,
|
|
347
|
+
)
|
|
348
|
+
except httpx.ConnectError as e:
|
|
349
|
+
raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
|
|
350
|
+
except httpx.TimeoutException as e:
|
|
351
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
352
|
+
|
|
353
|
+
if response.status_code != 200:
|
|
354
|
+
self._handle_error(response, "getting version")
|
|
355
|
+
|
|
356
|
+
return self._unwrap_response(response.json())
|
|
357
|
+
|
|
358
|
+
def get_project_artifacts(
|
|
359
|
+
self,
|
|
360
|
+
project_id: str,
|
|
361
|
+
version_id: str | None = None,
|
|
362
|
+
) -> dict[str, Any]:
|
|
363
|
+
"""Get project artifacts with full content (prompts, skills, tools).
|
|
364
|
+
|
|
365
|
+
This endpoint returns the actual content of each artifact, including
|
|
366
|
+
schema_content for tools, content for prompts, and metadata for skills.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
project_id: Project UUID.
|
|
370
|
+
version_id: Optional version UUID. If None, returns latest version's artifacts.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
API response with:
|
|
374
|
+
- prompts: list of prompt objects with 'content' field
|
|
375
|
+
- skills: list of skill objects with metadata
|
|
376
|
+
- tools: list of tools_schema objects with 'schema_content' field
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
AuthenticationError: If API key is invalid.
|
|
380
|
+
ProjectNotFoundError: If project doesn't exist.
|
|
381
|
+
NetworkError: If connection fails.
|
|
382
|
+
"""
|
|
383
|
+
url = f"{self.base_url}/api/v1/projects/{project_id}/artifacts"
|
|
384
|
+
params = {}
|
|
385
|
+
if version_id:
|
|
386
|
+
params["version_id"] = version_id
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
response = httpx.get(
|
|
390
|
+
url,
|
|
391
|
+
params=params,
|
|
392
|
+
headers=self._get_headers(),
|
|
393
|
+
timeout=self.timeout,
|
|
394
|
+
)
|
|
395
|
+
except httpx.ConnectError as e:
|
|
396
|
+
raise NetworkError(f"Failed to connect to {self.base_url}: {e}") from e
|
|
397
|
+
except httpx.TimeoutException as e:
|
|
398
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
399
|
+
|
|
400
|
+
if response.status_code != 200:
|
|
401
|
+
self._handle_error(response, "getting project artifacts")
|
|
402
|
+
|
|
403
|
+
return self._unwrap_response(response.json())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright 2026 zipsonken
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|