sprucelab 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.
sprucelab/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """sprucelab — agent-first surface for the Sprucelab platform.
2
+
3
+ Ships two transports — both designed for agents to drive fast, both
4
+ usable by humans at the terminal when they want to:
5
+
6
+ - ``spruce`` — typed CLI. Terminal-resident agents (Claude Code,
7
+ Aider, anything that drives a shell) bash-call ``spruce …`` and
8
+ read structured JSON / SSE off stdout. Humans get the same
9
+ commands with ``--help`` and pretty rendering.
10
+ - ``sprucelab-mcp`` — MCP server over stdio. Host-resident agents
11
+ (Claude Desktop, Cursor, Continue) launch this as a subprocess
12
+ and call the same operations as typed tool calls.
13
+
14
+ Both wrap the same public HTTP surface advertised at
15
+ ``/api/capabilities/``. The web app at sprucelab.io is the
16
+ human-first surface; these are the agent-first ones.
17
+ """
18
+
19
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ """Spruce CLI - Automation pipeline runner for Sprucelab."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Entry point for running as python -m sprucelab.cli."""
2
+ from .app import app
3
+
4
+ if __name__ == "__main__":
5
+ app()
sprucelab/cli/_auth.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ Single source of truth for resolving the CLI's API token.
3
+
4
+ Resolution order (first hit wins):
5
+ 1. Explicit ``--token`` flag passed to a command.
6
+ 2. ``SPRUCELAB_ADMIN_TOKEN`` environment variable (CI/scripts path).
7
+ 3. ``keyring`` (set by ``spruce auth register --token ...``).
8
+
9
+ This unifies the previously fragmented behavior where command modules
10
+ (``models``, ``verify``, ``types``, …) read only the env var while the
11
+ ``auth``/``capabilities`` modules read only the keyring. After this
12
+ helper, every command honors all three paths.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Optional
18
+
19
+
20
+ def resolve_token(override: Optional[str] = None) -> Optional[str]:
21
+ """Return the API token to use, or None if nothing is configured."""
22
+ if override:
23
+ return override
24
+ env_token = os.environ.get('SPRUCELAB_ADMIN_TOKEN')
25
+ if env_token:
26
+ return env_token
27
+ try:
28
+ from .config import get_api_key
29
+ kr_token = get_api_key()
30
+ if kr_token:
31
+ return kr_token
32
+ except Exception:
33
+ # Keyring backend missing or locked — fall through to None.
34
+ pass
35
+ return None
36
+
37
+
38
+ def auth_headers(override: Optional[str] = None) -> dict:
39
+ """Standard JSON request headers including Authorization if we have a token."""
40
+ headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
41
+ token = resolve_token(override)
42
+ if token:
43
+ headers['Authorization'] = f'Bearer {token}'
44
+ return headers
@@ -0,0 +1,174 @@
1
+ """
2
+ Verbatim "Try: ..." next-command hints for CLI errors.
3
+
4
+ Design rule (`feedback-agent-first-or-die`): error messages should suggest
5
+ the next command verbatim. Agents learn faster from
6
+ ``Try: spruce auth register --token <KEY>`` than from a bare
7
+ ``401 Unauthorized``.
8
+
9
+ Each command module passes a short ``command_context`` string (e.g.
10
+ ``"models list"``, ``"types classify"``, ``"webhooks deliveries"``) so we can
11
+ tailor the suggestion to the noun the user was working with.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from typing import Optional
18
+
19
+ import httpx
20
+ import typer
21
+
22
+
23
+ # Map (command_context_prefix -> noun used in `spruce <noun> list` hints).
24
+ # The first key whose prefix matches command_context wins. Fall back to the
25
+ # raw context string for the 5xx/network case where the hint isn't about a
26
+ # specific noun.
27
+ _NOUN_FOR_404 = {
28
+ 'models': 'models list',
29
+ 'types': 'types list --model <MODEL_UUID>',
30
+ 'verify': 'models list',
31
+ 'scripts run': 'scripts list',
32
+ 'scripts': 'scripts list',
33
+ 'webhooks deliveries': 'webhooks deliveries',
34
+ 'webhooks redeliver': 'webhooks deliveries',
35
+ 'webhooks test': 'webhooks list',
36
+ 'webhooks': 'webhooks list',
37
+ 'embed pass revoke': 'embed pass list',
38
+ 'embed pass refresh': 'embed pass list',
39
+ 'embed': 'embed pass list',
40
+ 'files show': 'files list',
41
+ 'files download': 'files list',
42
+ 'files reprocess': 'files list',
43
+ 'files versions': 'files list',
44
+ 'files upload': 'files list # confirm the project id',
45
+ 'files': 'files list',
46
+ 'log': 'log list',
47
+ }
48
+
49
+
50
+ def format_http_error_hint(status: int, command_context: str) -> Optional[str]:
51
+ """
52
+ Return a verbatim ``Try: ...`` suggestion for ``status`` in ``command_context``.
53
+
54
+ Returns ``None`` when no useful hint applies (e.g. unrecognized status).
55
+ The returned string does NOT include the ``Try: `` prefix or trailing
56
+ newline — caller decides framing (Rich vs JSON).
57
+ """
58
+ if status in (401, 403):
59
+ return (
60
+ 'spruce auth register --token <KEY> '
61
+ '# or: spruce auth status'
62
+ )
63
+
64
+ if status == 404:
65
+ for prefix, suggestion in _NOUN_FOR_404.items():
66
+ if command_context.startswith(prefix):
67
+ return f'spruce {suggestion} # confirm the ID exists'
68
+ return 'spruce capabilities # confirm the endpoint exists on this backend'
69
+
70
+ if status == 400 or status == 422:
71
+ # Validation errors. Echo the field error then nudge to re-read help.
72
+ return (
73
+ f'spruce {command_context} --help '
74
+ '# field error in payload above; check required flags'
75
+ )
76
+
77
+ if status >= 500:
78
+ return (
79
+ f'spruce {command_context} --json | jq .body '
80
+ '# inspect the server response in full'
81
+ )
82
+
83
+ if status == 405:
84
+ return 'spruce capabilities # this action may not be implemented on this backend yet'
85
+
86
+ return None
87
+
88
+
89
+ def format_request_error_hint() -> str:
90
+ """
91
+ Hint for connection / DNS / timeout errors (httpx.RequestError).
92
+
93
+ Always returns a non-empty string. Agents/users can't recover without
94
+ pointing at the URL config, so we always offer the same two breadcrumbs.
95
+ """
96
+ return (
97
+ 'spruce config show '
98
+ '# verify api_url; override with $SPRUCE_API_URL or `spruce auth register --url <URL>`'
99
+ )
100
+
101
+
102
+ def print_http_error(
103
+ console,
104
+ err: httpx.HTTPStatusError,
105
+ *,
106
+ json_out: bool,
107
+ command_context: str,
108
+ ) -> None:
109
+ """
110
+ Pretty-print an httpx.HTTPStatusError with a verbatim next-command hint.
111
+
112
+ Always raises ``typer.Exit(1)``. Caller supplies a Rich Console for human
113
+ output and a short ``command_context`` (e.g. ``"models list"``) so the
114
+ hint can be scoped.
115
+
116
+ JSON shape (preserved from the previous unstructured handlers, plus
117
+ ``hint`` and ``body`` keys):
118
+
119
+ {
120
+ "error": "HTTP <status>",
121
+ "status": <status>,
122
+ "body": <parsed JSON body or raw text>,
123
+ "hint": "<verbatim next command>" | null
124
+ }
125
+ """
126
+ body_text = err.response.text
127
+ parsed = None
128
+ try:
129
+ parsed = err.response.json()
130
+ except Exception:
131
+ pass
132
+
133
+ hint = format_http_error_hint(err.response.status_code, command_context)
134
+ body_payload = parsed if parsed is not None else body_text
135
+
136
+ if json_out:
137
+ payload = {
138
+ 'error': f'HTTP {err.response.status_code}',
139
+ 'status': err.response.status_code,
140
+ 'body': body_payload,
141
+ 'hint': hint,
142
+ }
143
+ sys.stdout.write(json.dumps(payload) + '\n')
144
+ else:
145
+ body_pretty = json.dumps(parsed, indent=2) if parsed is not None else body_text
146
+ console.print(f'[red]HTTP {err.response.status_code}[/red]\n{body_pretty}')
147
+ if hint:
148
+ console.print(f'[yellow]Try:[/yellow] [cyan]{hint}[/cyan]')
149
+ raise typer.Exit(1)
150
+
151
+
152
+ def print_request_error(
153
+ console,
154
+ err: httpx.RequestError,
155
+ *,
156
+ json_out: bool,
157
+ command_context: str,
158
+ ) -> None:
159
+ """
160
+ Pretty-print a connection / DNS / timeout error with a config hint.
161
+
162
+ Always raises ``typer.Exit(1)``.
163
+ """
164
+ hint = format_request_error_hint()
165
+ if json_out:
166
+ sys.stdout.write(json.dumps({
167
+ 'error': 'request_failed',
168
+ 'detail': str(err),
169
+ 'hint': hint,
170
+ }) + '\n')
171
+ else:
172
+ console.print(f'[red]Request failed:[/red] {err}')
173
+ console.print(f'[yellow]Try:[/yellow] [cyan]{hint}[/cyan]')
174
+ raise typer.Exit(1)
@@ -0,0 +1,226 @@
1
+ """HTTP client for Sprucelab API communication."""
2
+ from typing import Optional, Dict, Any, List
3
+ import httpx
4
+
5
+ from .config import get_api_url, get_api_key, get_agent_id, get_hostname
6
+
7
+
8
+ class SprucelabClient:
9
+ """Client for Sprucelab automation API."""
10
+
11
+ def __init__(self, api_url: Optional[str] = None, api_key: Optional[str] = None):
12
+ self.api_url = api_url or get_api_url()
13
+ self.api_key = api_key or get_api_key()
14
+ self.agent_id = get_agent_id()
15
+ self.hostname = get_hostname()
16
+
17
+ def _headers(self) -> Dict[str, str]:
18
+ """Get request headers with authentication."""
19
+ headers = {"Content-Type": "application/json"}
20
+ if self.api_key:
21
+ headers["Authorization"] = f"Bearer {self.api_key}"
22
+ return headers
23
+
24
+ def _url(self, path: str) -> str:
25
+ """Build full URL for API path."""
26
+ return f"{self.api_url}/api/automation{path}"
27
+
28
+ # Pipeline endpoints
29
+
30
+ def list_pipelines(self) -> List[Dict[str, Any]]:
31
+ """List all available pipelines."""
32
+ with httpx.Client() as client:
33
+ response = client.get(
34
+ self._url("/pipelines/"),
35
+ headers=self._headers()
36
+ )
37
+ response.raise_for_status()
38
+ return response.json()
39
+
40
+ def get_pipeline(self, pipeline_id: str) -> Dict[str, Any]:
41
+ """Get pipeline details."""
42
+ with httpx.Client() as client:
43
+ response = client.get(
44
+ self._url(f"/pipelines/{pipeline_id}/"),
45
+ headers=self._headers()
46
+ )
47
+ response.raise_for_status()
48
+ return response.json()
49
+
50
+ def trigger_run(
51
+ self,
52
+ pipeline_id: str,
53
+ project_id: Optional[str] = None,
54
+ parameters: Optional[Dict] = None
55
+ ) -> Dict[str, Any]:
56
+ """Trigger a pipeline run."""
57
+ data = {
58
+ "triggered_by": f"cli:{self.hostname}"
59
+ }
60
+ if project_id:
61
+ data["project_id"] = project_id
62
+ if parameters:
63
+ data["parameters"] = parameters
64
+
65
+ with httpx.Client() as client:
66
+ response = client.post(
67
+ self._url(f"/pipelines/{pipeline_id}/run/"),
68
+ headers=self._headers(),
69
+ json=data
70
+ )
71
+ response.raise_for_status()
72
+ return response.json()
73
+
74
+ # Run endpoints
75
+
76
+ def list_runs(
77
+ self,
78
+ pipeline_id: Optional[str] = None,
79
+ project_id: Optional[str] = None,
80
+ status: Optional[str] = None
81
+ ) -> List[Dict[str, Any]]:
82
+ """List pipeline runs."""
83
+ params = {}
84
+ if pipeline_id:
85
+ params["pipeline"] = pipeline_id
86
+ if project_id:
87
+ params["project"] = project_id
88
+ if status:
89
+ params["status"] = status
90
+
91
+ with httpx.Client() as client:
92
+ response = client.get(
93
+ self._url("/runs/"),
94
+ headers=self._headers(),
95
+ params=params
96
+ )
97
+ response.raise_for_status()
98
+ return response.json()
99
+
100
+ def get_run(self, run_id: str) -> Dict[str, Any]:
101
+ """Get run details."""
102
+ with httpx.Client() as client:
103
+ response = client.get(
104
+ self._url(f"/runs/{run_id}/"),
105
+ headers=self._headers()
106
+ )
107
+ response.raise_for_status()
108
+ return response.json()
109
+
110
+ def get_run_logs(self, run_id: str) -> Dict[str, Any]:
111
+ """Get run logs."""
112
+ with httpx.Client() as client:
113
+ response = client.get(
114
+ self._url(f"/runs/{run_id}/logs/"),
115
+ headers=self._headers()
116
+ )
117
+ response.raise_for_status()
118
+ return response.json()
119
+
120
+ # Agent endpoints
121
+
122
+ def register_agent(self, name: str) -> Dict[str, Any]:
123
+ """Register this machine as an agent."""
124
+ with httpx.Client() as client:
125
+ response = client.post(
126
+ self._url("/agent/register/"),
127
+ headers=self._headers(),
128
+ json={
129
+ "name": name,
130
+ "hostname": self.hostname,
131
+ "capabilities": ["ifcopenshell", "python"]
132
+ }
133
+ )
134
+ response.raise_for_status()
135
+ return response.json()
136
+
137
+ def heartbeat(self) -> bool:
138
+ """Send agent heartbeat."""
139
+ if not self.agent_id:
140
+ return False
141
+
142
+ with httpx.Client() as client:
143
+ try:
144
+ response = client.post(
145
+ self._url("/agent/heartbeat/"),
146
+ headers=self._headers(),
147
+ json={
148
+ "agent_id": self.agent_id,
149
+ "hostname": self.hostname
150
+ }
151
+ )
152
+ return response.status_code == 200
153
+ except Exception:
154
+ return False
155
+
156
+ def poll_jobs(self) -> List[Dict[str, Any]]:
157
+ """Poll for pending jobs."""
158
+ if not self.agent_id:
159
+ return []
160
+
161
+ with httpx.Client() as client:
162
+ response = client.get(
163
+ self._url("/agent/jobs/"),
164
+ headers=self._headers(),
165
+ params={"agent_id": self.agent_id}
166
+ )
167
+ response.raise_for_status()
168
+ return response.json().get("jobs", [])
169
+
170
+ def claim_job(self, run_id: str) -> bool:
171
+ """Claim a job for execution."""
172
+ with httpx.Client() as client:
173
+ response = client.post(
174
+ self._url(f"/agent/jobs/{run_id}/claim/"),
175
+ headers=self._headers(),
176
+ json={
177
+ "agent_id": self.agent_id,
178
+ "agent_hostname": self.hostname
179
+ }
180
+ )
181
+ return response.status_code == 200
182
+
183
+ def step_start(self, run_id: str, step_id: str) -> bool:
184
+ """Mark a step as started."""
185
+ with httpx.Client() as client:
186
+ response = client.post(
187
+ self._url(f"/agent/jobs/{run_id}/step/{step_id}/start/"),
188
+ headers=self._headers()
189
+ )
190
+ return response.status_code == 200
191
+
192
+ def step_complete(
193
+ self,
194
+ run_id: str,
195
+ step_id: str,
196
+ status: str,
197
+ output_log: str = "",
198
+ result_data: Optional[Dict] = None,
199
+ error_message: str = "",
200
+ output_files: Optional[List[str]] = None
201
+ ) -> bool:
202
+ """Mark a step as completed."""
203
+ with httpx.Client() as client:
204
+ response = client.post(
205
+ self._url(f"/agent/jobs/{run_id}/step/{step_id}/complete/"),
206
+ headers=self._headers(),
207
+ json={
208
+ "status": status,
209
+ "output_log": output_log,
210
+ "result_data": result_data or {},
211
+ "error_message": error_message,
212
+ "output_files": output_files or []
213
+ }
214
+ )
215
+ return response.status_code == 200
216
+
217
+ def run_complete(self, run_id: str, error_message: str = "") -> Dict[str, Any]:
218
+ """Mark a run as completed."""
219
+ with httpx.Client() as client:
220
+ response = client.post(
221
+ self._url(f"/agent/jobs/{run_id}/complete/"),
222
+ headers=self._headers(),
223
+ json={"error_message": error_message}
224
+ )
225
+ response.raise_for_status()
226
+ return response.json()