senselab-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.
cli/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """SenseLab CLI package."""
2
+
3
+ __version__ = "0.1.0"
4
+
cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from cli.main import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
cli/api.py ADDED
@@ -0,0 +1,205 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+ from urllib.parse import urljoin
5
+
6
+
7
+ @dataclass
8
+ class APIError(Exception):
9
+ message: str
10
+ status_code: int | None = None
11
+ suggestion: str | None = None
12
+
13
+ def __str__(self) -> str:
14
+ if self.suggestion:
15
+ return f"{self.message} {self.suggestion}"
16
+ return self.message
17
+
18
+
19
+ class APIClient:
20
+ def __init__(self, base_url: str, token: str | None, timeout_seconds: int = 60):
21
+ self.base_url = base_url.rstrip("/")
22
+ self.token = token
23
+ self.timeout_seconds = timeout_seconds
24
+
25
+ def _headers(self) -> dict[str, str]:
26
+ headers = {"Content-Type": "application/json"}
27
+ if self.token:
28
+ headers["Authorization"] = f"Bearer {self.token}"
29
+ return headers
30
+
31
+ def _request(
32
+ self,
33
+ method: str,
34
+ path: str,
35
+ *,
36
+ json_body: dict[str, Any] | None = None,
37
+ params: dict[str, Any] | None = None,
38
+ timeout_seconds: int | None = None,
39
+ ) -> Any:
40
+ try:
41
+ import requests
42
+ except ImportError as e:
43
+ raise APIError(
44
+ "Missing dependency 'requests'. Install project dependencies to use CLI API calls."
45
+ ) from e
46
+
47
+ if not self.token:
48
+ raise APIError(
49
+ "Not logged in.",
50
+ suggestion="Run `sense-cli login` or set SENSELAB_TOKEN.",
51
+ )
52
+
53
+ url = urljoin(f"{self.base_url}/", path.lstrip("/"))
54
+ try:
55
+ response = requests.request(
56
+ method=method,
57
+ url=url,
58
+ headers=self._headers(),
59
+ json=json_body,
60
+ params=params,
61
+ timeout=timeout_seconds or self.timeout_seconds,
62
+ )
63
+ except requests.Timeout as e:
64
+ raise APIError(
65
+ "Request timed out while contacting SenseLab API.",
66
+ suggestion="Try again or check backend availability.",
67
+ ) from e
68
+ except requests.RequestException as e:
69
+ raise APIError(
70
+ f"Failed to reach API: {e}",
71
+ suggestion="Check network connectivity and --base-url.",
72
+ ) from e
73
+
74
+ if response.status_code == 401:
75
+ raise APIError(
76
+ "Not logged in.",
77
+ status_code=401,
78
+ suggestion="Run `sense-cli login` or set SENSELAB_TOKEN.",
79
+ )
80
+
81
+ if response.status_code >= 400:
82
+ try:
83
+ payload = response.json()
84
+ message = payload.get("message") or json.dumps(payload)
85
+ except Exception:
86
+ message = response.text or "Unexpected API error."
87
+ raise APIError(message, status_code=response.status_code)
88
+
89
+ if not response.text:
90
+ return {}
91
+ try:
92
+ return response.json()
93
+ except Exception:
94
+ return response.text
95
+
96
+ def whoami(self) -> dict[str, Any]:
97
+ result = self._request("GET", "/api/whoami/")
98
+ return result if isinstance(result, dict) else {"raw": result}
99
+
100
+ def get_projects(self) -> list[dict[str, Any]]:
101
+ result = self._request("GET", "/api/projects")
102
+ return result if isinstance(result, list) else []
103
+
104
+ def get_specialists(self, project_guid: str) -> list[dict[str, Any]]:
105
+ result = self._request("GET", f"/api/specialists/{project_guid}")
106
+ return result if isinstance(result, list) else []
107
+
108
+ def get_functions(
109
+ self, project_guid: str, specialist_guid: str
110
+ ) -> list[dict[str, Any]]:
111
+ result = self._request(
112
+ "GET", f"/api/specialists/{project_guid}/workflows/{specialist_guid}"
113
+ )
114
+ return result if isinstance(result, list) else []
115
+
116
+ def run_orchestrator_sync(
117
+ self, project_guid: str, query: str, conversation_guid: str | None = None
118
+ ) -> dict[str, Any]:
119
+ payload: dict[str, Any] = {"query": query}
120
+ if conversation_guid:
121
+ payload["conversation_guid"] = conversation_guid
122
+ return self._request(
123
+ "POST",
124
+ f"/api/orchestrator/{project_guid}/run-sync",
125
+ json_body=payload,
126
+ timeout_seconds=600,
127
+ )
128
+
129
+ def run_specialist_sync(
130
+ self,
131
+ project_guid: str,
132
+ specialist_guid: str,
133
+ query: str,
134
+ conversation_guid: str | None = None,
135
+ ) -> dict[str, Any]:
136
+ payload: dict[str, Any] = {"query": query, "specialist_guid": specialist_guid}
137
+ if conversation_guid:
138
+ payload["conversation_guid"] = conversation_guid
139
+ return self._request(
140
+ "POST",
141
+ f"/api/specialist/{project_guid}/run-sync",
142
+ json_body=payload,
143
+ timeout_seconds=600,
144
+ )
145
+
146
+ def execute_function(
147
+ self, project_guid: str, specialist_guid: str, workflow_id: str
148
+ ) -> dict[str, Any]:
149
+ return self._request(
150
+ "POST",
151
+ f"/api/specialists/{project_guid}/workflows/{specialist_guid}/{workflow_id}",
152
+ )
153
+
154
+ def get_execution_status(
155
+ self, project_guid: str, workflow_id: str, execution_guid: str
156
+ ) -> dict[str, Any]:
157
+ result = self._request(
158
+ "GET",
159
+ f"/api/specialists/{project_guid}/workflows/{workflow_id}/{execution_guid}",
160
+ )
161
+ return result if isinstance(result, dict) else {"raw": result}
162
+
163
+ def list_connectors(self) -> list[dict[str, Any]]:
164
+ result = self._request("GET", "/api/connectors/list")
165
+ return result if isinstance(result, list) else []
166
+
167
+ def list_connector_instances(self, project_guid: str) -> list[dict[str, Any]]:
168
+ result = self._request("GET", f"/api/connectors/{project_guid}")
169
+ return result if isinstance(result, list) else []
170
+
171
+ def create_specialist(
172
+ self, project_guid: str, payload: dict[str, Any]
173
+ ) -> dict[str, Any]:
174
+ return self._request("POST", f"/api/specialists/{project_guid}", json_body=payload)
175
+
176
+ def update_specialist(
177
+ self, project_guid: str, specialist_guid: str, payload: dict[str, Any]
178
+ ) -> dict[str, Any]:
179
+ return self._request(
180
+ "PUT",
181
+ f"/api/specialists/{project_guid}/{specialist_guid}",
182
+ json_body=payload,
183
+ )
184
+
185
+ def configure_specialist(
186
+ self, project_guid: str, specialist_guid: str, payload: dict[str, Any]
187
+ ) -> dict[str, Any]:
188
+ return self._request(
189
+ "POST",
190
+ f"/api/specialists/{project_guid}/{specialist_guid}/config",
191
+ json_body=payload,
192
+ )
193
+
194
+ def create_specialist_workflows(
195
+ self,
196
+ project_guid: str,
197
+ specialist_guid: str,
198
+ workflows: list[dict[str, Any]],
199
+ ) -> list[dict[str, Any]] | dict[str, Any]:
200
+ return self._request(
201
+ "POST",
202
+ f"/api/specialists/{project_guid}/workflows/{specialist_guid}",
203
+ json_body={"workflows": workflows},
204
+ )
205
+
cli/apply.py ADDED
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ import yaml
9
+
10
+ from cli.api import APIClient, APIError
11
+
12
+
13
+ @dataclass
14
+ class ApplyResult:
15
+ specialist_guid: str
16
+ connectors_attached: int
17
+ workflows_created: int
18
+ config_applied: bool
19
+
20
+
21
+ def _is_uuid(value: str) -> bool:
22
+ try:
23
+ UUID(value)
24
+ return True
25
+ except Exception:
26
+ return False
27
+
28
+
29
+ def _extract_credential_guid(item: dict[str, Any]) -> str | None:
30
+ for key in ("tool_cred_guid", "cred_guid", "db_secret", "guid", "id"):
31
+ value = item.get(key)
32
+ if value:
33
+ return str(value)
34
+ return None
35
+
36
+
37
+ def _extract_credential_name(item: dict[str, Any]) -> str | None:
38
+ for key in ("tool_cred_name", "cred_name", "connection_name", "name"):
39
+ value = item.get(key)
40
+ if value:
41
+ return str(value)
42
+ return None
43
+
44
+
45
+ def _resolve_connector_reference(
46
+ connector_ref: str, connector_instances: list[dict[str, Any]]
47
+ ) -> str:
48
+ if _is_uuid(connector_ref):
49
+ return connector_ref
50
+
51
+ for item in connector_instances:
52
+ if _extract_credential_name(item) == connector_ref:
53
+ cred_guid = _extract_credential_guid(item)
54
+ if cred_guid:
55
+ return cred_guid
56
+
57
+ raise APIError(
58
+ f"Connector reference '{connector_ref}' not found in project connector instances."
59
+ )
60
+
61
+
62
+ def _build_specialist_payload(spec: dict[str, Any]) -> dict[str, Any]:
63
+ required = ("name", "system_prompt", "prompt_template", "role_guid", "llm_guid")
64
+ missing = [field for field in required if not spec.get(field)]
65
+ if missing:
66
+ raise APIError(f"assistant is missing required fields: {', '.join(missing)}")
67
+
68
+ payload: dict[str, Any] = {
69
+ "name": spec["name"],
70
+ "system_prompt": spec["system_prompt"],
71
+ "prompt_template": spec["prompt_template"],
72
+ "role_guid": spec["role_guid"],
73
+ "llm_guid": spec["llm_guid"],
74
+ }
75
+ if spec.get("description"):
76
+ payload["description"] = spec["description"]
77
+ if spec.get("is_default") is not None:
78
+ payload["is_default"] = bool(spec["is_default"])
79
+ return payload
80
+
81
+
82
+ def _build_config_payload(connectors: list[dict[str, Any]]) -> dict[str, Any]:
83
+ config_payload: dict[str, Any] = {}
84
+ generic_items: list[dict[str, Any]] = []
85
+ for item in connectors:
86
+ connector_type = item.get("type")
87
+ cred_guid = item["resolved_cred_guid"]
88
+ config = item.get("config", {})
89
+
90
+ if connector_type == "github_repos":
91
+ config_payload["github_repos"] = {
92
+ "tool_cred_guid": cred_guid,
93
+ "repos": config.get("repos", []),
94
+ }
95
+ elif connector_type == "gitlab_repos":
96
+ config_payload["gitlab_repos"] = {
97
+ "tool_cred_guid": cred_guid,
98
+ "repos": config.get("repos", []),
99
+ }
100
+ elif connector_type == "aws_services":
101
+ config_payload["aws_services"] = {
102
+ "tool_cred_guid": cred_guid,
103
+ "region": config.get("region", ""),
104
+ "services": config.get("services", []),
105
+ "context": config.get("context", ""),
106
+ }
107
+ elif connector_type == "gcp_services":
108
+ config_payload["gcp_services"] = {
109
+ "tool_cred_guid": cred_guid,
110
+ "region": config.get("region", ""),
111
+ "services": config.get("services", []),
112
+ "context": config.get("context", ""),
113
+ }
114
+ elif connector_type == "generic":
115
+ generic_items.append(
116
+ {
117
+ "tool_cred_guid": cred_guid,
118
+ "plugin": config.get("plugin", ""),
119
+ "context": config.get("context", ""),
120
+ }
121
+ )
122
+ if generic_items:
123
+ config_payload["generic"] = generic_items
124
+ return config_payload
125
+
126
+
127
+ def _build_workflows_payload(functions: list[dict[str, Any]]) -> list[dict[str, Any]]:
128
+ workflows: list[dict[str, Any]] = []
129
+ for function in functions:
130
+ if not isinstance(function, dict):
131
+ raise APIError("functions entries must be objects.")
132
+ if not function.get("workflow_name") or not function.get("definition"):
133
+ raise APIError(
134
+ "Each function must include workflow_name and definition fields."
135
+ )
136
+ workflows.append(
137
+ {
138
+ "workflow_name": function["workflow_name"],
139
+ "definition": function["definition"],
140
+ "variables": function.get("variables"),
141
+ "schema_version": function.get("schema_version", "1.0.0"),
142
+ }
143
+ )
144
+ return workflows
145
+
146
+
147
+ def load_apply_file(path: str) -> dict[str, Any]:
148
+ apply_path = Path(path).expanduser()
149
+ if not apply_path.exists():
150
+ raise APIError(f"YAML file not found: {apply_path}")
151
+
152
+ with apply_path.open("r", encoding="utf-8") as f:
153
+ parsed = yaml.safe_load(f)
154
+ if not isinstance(parsed, dict):
155
+ raise APIError("YAML root must be an object.")
156
+ return parsed
157
+
158
+
159
+ def apply_assistant_config(
160
+ api: APIClient,
161
+ project_guid: str,
162
+ payload: dict[str, Any],
163
+ dry_run: bool = False,
164
+ ) -> ApplyResult:
165
+ assistant = payload.get("assistant")
166
+ if not isinstance(assistant, dict):
167
+ raise APIError("Missing required 'assistant' section in YAML.")
168
+
169
+ connectors = payload.get("connectors", [])
170
+ if connectors is None:
171
+ connectors = []
172
+ if not isinstance(connectors, list):
173
+ raise APIError("'connectors' must be a list.")
174
+
175
+ functions = payload.get("functions", [])
176
+ if functions is None:
177
+ functions = []
178
+ if not isinstance(functions, list):
179
+ raise APIError("'functions' must be a list.")
180
+
181
+ specialist_payload = _build_specialist_payload(assistant)
182
+ connector_instances = api.list_connector_instances(project_guid)
183
+
184
+ resolved_connectors: list[dict[str, Any]] = []
185
+ for connector in connectors:
186
+ if not isinstance(connector, dict):
187
+ raise APIError("Each connector entry must be an object.")
188
+ connector_ref = connector.get("connector")
189
+ if not connector_ref:
190
+ raise APIError("Each connector entry must include 'connector'.")
191
+ resolved_guid = _resolve_connector_reference(str(connector_ref), connector_instances)
192
+ resolved_connectors.append({**connector, "resolved_cred_guid": resolved_guid})
193
+
194
+ workflows_payload = _build_workflows_payload(functions)
195
+ config_payload = _build_config_payload(resolved_connectors)
196
+ tool_cred_guids = [item["resolved_cred_guid"] for item in resolved_connectors]
197
+
198
+ if dry_run:
199
+ return ApplyResult(
200
+ specialist_guid="dry-run",
201
+ connectors_attached=len(tool_cred_guids),
202
+ workflows_created=len(workflows_payload),
203
+ config_applied=bool(config_payload),
204
+ )
205
+
206
+ create_payload = {**specialist_payload}
207
+ if tool_cred_guids:
208
+ create_payload["tool_cred_guids"] = tool_cred_guids
209
+ specialist_result = api.create_specialist(project_guid, create_payload)
210
+ specialist_guid = specialist_result.get("specialist_guid")
211
+ if not specialist_guid:
212
+ raise APIError("Failed to create specialist: missing specialist_guid in response.")
213
+
214
+ if config_payload:
215
+ api.configure_specialist(project_guid, specialist_guid, config_payload)
216
+
217
+ if workflows_payload:
218
+ api.create_specialist_workflows(project_guid, specialist_guid, workflows_payload)
219
+
220
+ return ApplyResult(
221
+ specialist_guid=str(specialist_guid),
222
+ connectors_attached=len(tool_cred_guids),
223
+ workflows_created=len(workflows_payload),
224
+ config_applied=bool(config_payload),
225
+ )
226
+
cli/config.py ADDED
@@ -0,0 +1,65 @@
1
+ import json
2
+ import os
3
+ from dataclasses import asdict, dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ DEFAULT_API_URL = "https://api.raia.live"
9
+ DEFAULT_CONFIG_PATH = Path.home() / ".sense-cli" / "config.json"
10
+
11
+
12
+ @dataclass
13
+ class CLIConfig:
14
+ base_url: str = DEFAULT_API_URL
15
+ token: str | None = None
16
+ default_project_guid: str | None = None
17
+ email: str | None = None
18
+ frontend_url: str | None = None
19
+ telemetry_disabled: bool = False
20
+
21
+ @classmethod
22
+ def from_dict(cls, raw: dict[str, Any]) -> "CLIConfig":
23
+ return cls(
24
+ base_url=raw.get("base_url", DEFAULT_API_URL),
25
+ token=raw.get("token"),
26
+ default_project_guid=raw.get("default_project_guid"),
27
+ email=raw.get("email"),
28
+ frontend_url=raw.get("frontend_url"),
29
+ telemetry_disabled=bool(raw.get("telemetry_disabled", False)),
30
+ )
31
+
32
+
33
+ def _ensure_parent(path: Path) -> None:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+
36
+
37
+ def get_config_path() -> Path:
38
+ custom = os.getenv("SENSELAB_CONFIG_PATH")
39
+ return Path(custom).expanduser() if custom else DEFAULT_CONFIG_PATH
40
+
41
+
42
+ def load_config() -> CLIConfig:
43
+ path = get_config_path()
44
+ if not path.exists():
45
+ return CLIConfig()
46
+
47
+ with path.open("r", encoding="utf-8") as f:
48
+ raw = json.load(f)
49
+ config = CLIConfig.from_dict(raw)
50
+
51
+ env_api = os.getenv("SENSELAB_API_URL")
52
+ env_token = os.getenv("SENSELAB_TOKEN")
53
+ if env_api:
54
+ config.base_url = env_api
55
+ if env_token:
56
+ config.token = env_token
57
+ return config
58
+
59
+
60
+ def save_config(config: CLIConfig) -> None:
61
+ path = get_config_path()
62
+ _ensure_parent(path)
63
+ with path.open("w", encoding="utf-8") as f:
64
+ json.dump(asdict(config), f, indent=2, sort_keys=True)
65
+