happy-engineering-sdk 0.1.0__tar.gz

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.
Files changed (33) hide show
  1. happy_engineering_sdk-0.1.0/.gitignore +11 -0
  2. happy_engineering_sdk-0.1.0/PKG-INFO +114 -0
  3. happy_engineering_sdk-0.1.0/README.md +89 -0
  4. happy_engineering_sdk-0.1.0/happy_sdk/__init__.py +27 -0
  5. happy_engineering_sdk-0.1.0/happy_sdk/api.py +56 -0
  6. happy_engineering_sdk-0.1.0/happy_sdk/client.py +255 -0
  7. happy_engineering_sdk-0.1.0/happy_sdk/credentials.py +44 -0
  8. happy_engineering_sdk-0.1.0/happy_sdk/encryption.py +135 -0
  9. happy_engineering_sdk-0.1.0/happy_sdk/exceptions.py +26 -0
  10. happy_engineering_sdk-0.1.0/happy_sdk/session_connection.py +121 -0
  11. happy_engineering_sdk-0.1.0/happy_sdk/socket_manager.py +100 -0
  12. happy_engineering_sdk-0.1.0/happy_sdk/types.py +29 -0
  13. happy_engineering_sdk-0.1.0/pyproject.toml +45 -0
  14. happy_engineering_sdk-0.1.0/scripts/publish.sh +23 -0
  15. happy_engineering_sdk-0.1.0/spec/.metadata.json +1 -0
  16. happy_engineering_sdk-0.1.0/spec/chores/publish-happy-engineering-sdk.md +13 -0
  17. happy_engineering_sdk-0.1.0/spec/functional-backlog.md +3 -0
  18. happy_engineering_sdk-0.1.0/spec/functional.md +9 -0
  19. happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/.metadata.json +29 -0
  20. happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/deviations.md +5 -0
  21. happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/hld.md +75 -0
  22. happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/lld.md +369 -0
  23. happy_engineering_sdk-0.1.0/spec/implementation/complete/1-python-client/tasks.md +160 -0
  24. happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/.metadata.json +1 -0
  25. happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/deviations.md +24 -0
  26. happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/hld.md +101 -0
  27. happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/lld.md +183 -0
  28. happy_engineering_sdk-0.1.0/spec/implementation/complete/2-pypi-publish/tasks.md +70 -0
  29. happy_engineering_sdk-0.1.0/spec/infrastructure.md +25 -0
  30. happy_engineering_sdk-0.1.0/spec/project.md +20 -0
  31. happy_engineering_sdk-0.1.0/tests/__init__.py +0 -0
  32. happy_engineering_sdk-0.1.0/tests/test_e2e.py +84 -0
  33. happy_engineering_sdk-0.1.0/tests/test_encryption.py +185 -0
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ .env
10
+ *.egg
11
+ .pytest_cache/
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: happy-engineering-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for controlling Happy agent sessions
5
+ Project-URL: Homepage, https://happy.engineering
6
+ Author-email: Scott Fraser <scott@jascro.com>
7
+ License: MIT
8
+ Keywords: agents,ai,engineering,happy,llm,sdk
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: cryptography>=43.0
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pynacl>=1.5
20
+ Requires-Dist: python-socketio[asyncio]>=5.11
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # happy-engineering-sdk
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/happy-engineering-sdk)](https://pypi.org/project/happy-engineering-sdk/)
29
+ [![Python versions](https://img.shields.io/pypi/pyversions/happy-engineering-sdk)](https://pypi.org/project/happy-engineering-sdk/)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
31
+
32
+ Python SDK for controlling Happy agent sessions.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install happy-engineering-sdk
38
+ ```
39
+
40
+ ## Credentials
41
+
42
+ The SDK authenticates using a key file and a server URL.
43
+
44
+ **Key file** — download `agent.key` from the Happy dashboard and place it at:
45
+
46
+ ```
47
+ ~/.happy/agent.key
48
+ ```
49
+
50
+ **Server URL** — set the `HAPPY_SERVER_URL` environment variable to your Happy
51
+ server's base URL:
52
+
53
+ ```bash
54
+ export HAPPY_SERVER_URL=https://api.happy.engineering
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ The simplest usage is `run_task()` — it spawns a session, sends a prompt, waits
60
+ for the agent to finish, and stops the session, all in one call:
61
+
62
+ ```python
63
+ import asyncio
64
+ from happy_sdk import HappyClient
65
+
66
+ async def main():
67
+ client = HappyClient()
68
+ session_id = await client.run_task(
69
+ machine_id="my-machine",
70
+ directory="/home/user/project",
71
+ prompt="Summarise this week's PRs",
72
+ )
73
+ print(f"Task complete — session {session_id}")
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ## Manual lifecycle
79
+
80
+ For finer control, manage the session lifecycle directly:
81
+
82
+ ```python
83
+ import asyncio
84
+ from happy_sdk import HappyClient
85
+
86
+ async def main():
87
+ client = HappyClient()
88
+
89
+ session_id = await client.spawn_session(
90
+ machine_id="my-machine",
91
+ directory="/home/user/project",
92
+ )
93
+ await client.send_message(session_id, "Hello")
94
+ await client.wait_for_turn_completion(session_id)
95
+ await client.stop_session(session_id)
96
+
97
+ asyncio.run(main())
98
+ ```
99
+
100
+ ## API reference
101
+
102
+ All methods are `async` and must be called from an async context.
103
+
104
+ | Method | Signature | Description |
105
+ |--------|-----------|-------------|
106
+ | `run_task` | `(machine_id, directory, prompt, agent="claude", timeout_seconds=600)` | Spawn a session, send a prompt, wait for turn completion, and stop — returns the session ID |
107
+ | `spawn_session` | `(machine_id, directory, agent="claude", create_dir=False)` | Create a new agent session on the given machine in the given directory — returns the session ID |
108
+ | `send_message` | `(session_id, text, permission_mode="yolo")` | Send a message to an active session |
109
+ | `wait_for_turn_completion` | `(session_id, timeout_seconds=300)` | Block until the agent finishes its current turn |
110
+ | `stop_session` | `(session_id)` | Stop a running session |
111
+
112
+ ## License
113
+
114
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,89 @@
1
+ # happy-engineering-sdk
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/happy-engineering-sdk)](https://pypi.org/project/happy-engineering-sdk/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/happy-engineering-sdk)](https://pypi.org/project/happy-engineering-sdk/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ Python SDK for controlling Happy agent sessions.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install happy-engineering-sdk
13
+ ```
14
+
15
+ ## Credentials
16
+
17
+ The SDK authenticates using a key file and a server URL.
18
+
19
+ **Key file** — download `agent.key` from the Happy dashboard and place it at:
20
+
21
+ ```
22
+ ~/.happy/agent.key
23
+ ```
24
+
25
+ **Server URL** — set the `HAPPY_SERVER_URL` environment variable to your Happy
26
+ server's base URL:
27
+
28
+ ```bash
29
+ export HAPPY_SERVER_URL=https://api.happy.engineering
30
+ ```
31
+
32
+ ## Quick start
33
+
34
+ The simplest usage is `run_task()` — it spawns a session, sends a prompt, waits
35
+ for the agent to finish, and stops the session, all in one call:
36
+
37
+ ```python
38
+ import asyncio
39
+ from happy_sdk import HappyClient
40
+
41
+ async def main():
42
+ client = HappyClient()
43
+ session_id = await client.run_task(
44
+ machine_id="my-machine",
45
+ directory="/home/user/project",
46
+ prompt="Summarise this week's PRs",
47
+ )
48
+ print(f"Task complete — session {session_id}")
49
+
50
+ asyncio.run(main())
51
+ ```
52
+
53
+ ## Manual lifecycle
54
+
55
+ For finer control, manage the session lifecycle directly:
56
+
57
+ ```python
58
+ import asyncio
59
+ from happy_sdk import HappyClient
60
+
61
+ async def main():
62
+ client = HappyClient()
63
+
64
+ session_id = await client.spawn_session(
65
+ machine_id="my-machine",
66
+ directory="/home/user/project",
67
+ )
68
+ await client.send_message(session_id, "Hello")
69
+ await client.wait_for_turn_completion(session_id)
70
+ await client.stop_session(session_id)
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ ## API reference
76
+
77
+ All methods are `async` and must be called from an async context.
78
+
79
+ | Method | Signature | Description |
80
+ |--------|-----------|-------------|
81
+ | `run_task` | `(machine_id, directory, prompt, agent="claude", timeout_seconds=600)` | Spawn a session, send a prompt, wait for turn completion, and stop — returns the session ID |
82
+ | `spawn_session` | `(machine_id, directory, agent="claude", create_dir=False)` | Create a new agent session on the given machine in the given directory — returns the session ID |
83
+ | `send_message` | `(session_id, text, permission_mode="yolo")` | Send a message to an active session |
84
+ | `wait_for_turn_completion` | `(session_id, timeout_seconds=300)` | Block until the agent finishes its current turn |
85
+ | `stop_session` | `(session_id)` | Stop a running session |
86
+
87
+ ## License
88
+
89
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,27 @@
1
+ from .client import HappyClient
2
+ from .types import Agent, Machine, Message, PermissionMode, Session
3
+ from .exceptions import (
4
+ AuthenticationError,
5
+ ConnectionError,
6
+ EncryptionError,
7
+ HappyError,
8
+ MachineOfflineError,
9
+ SpawnError,
10
+ TimeoutError,
11
+ )
12
+
13
+ __all__ = [
14
+ "HappyClient",
15
+ "Machine",
16
+ "Session",
17
+ "Message",
18
+ "Agent",
19
+ "PermissionMode",
20
+ "HappyError",
21
+ "AuthenticationError",
22
+ "ConnectionError",
23
+ "EncryptionError",
24
+ "MachineOfflineError",
25
+ "SpawnError",
26
+ "TimeoutError",
27
+ ]
@@ -0,0 +1,56 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from .exceptions import AuthenticationError, HappyError
6
+
7
+
8
+ class HappyAPI:
9
+ def __init__(self, server_url: str, token: str) -> None:
10
+ self._base_url = server_url.rstrip("/")
11
+ self._token = token
12
+
13
+ def _headers(self) -> dict[str, str]:
14
+ return {"Authorization": f"Bearer {self._token}"}
15
+
16
+ async def list_machines(self) -> list[dict[str, Any]]:
17
+ async with httpx.AsyncClient() as client:
18
+ r = await client.get(f"{self._base_url}/v1/machines", headers=self._headers())
19
+ r.raise_for_status()
20
+ return r.json()
21
+
22
+ async def list_sessions(self) -> list[dict[str, Any]]:
23
+ async with httpx.AsyncClient() as client:
24
+ r = await client.get(f"{self._base_url}/v1/sessions", headers=self._headers())
25
+ r.raise_for_status()
26
+ return r.json()
27
+
28
+ async def list_active_sessions(self) -> list[dict[str, Any]]:
29
+ async with httpx.AsyncClient() as client:
30
+ r = await client.get(f"{self._base_url}/v2/sessions/active", headers=self._headers())
31
+ r.raise_for_status()
32
+ return r.json()
33
+
34
+ async def get_session(self, session_id: str) -> dict[str, Any]:
35
+ async with httpx.AsyncClient() as client:
36
+ r = await client.get(
37
+ f"{self._base_url}/v1/sessions/{session_id}", headers=self._headers()
38
+ )
39
+ r.raise_for_status()
40
+ return r.json()
41
+
42
+ async def get_messages(self, session_id: str) -> list[dict[str, Any]]:
43
+ async with httpx.AsyncClient() as client:
44
+ r = await client.get(
45
+ f"{self._base_url}/v1/sessions/{session_id}/messages",
46
+ headers=self._headers(),
47
+ )
48
+ r.raise_for_status()
49
+ return r.json()
50
+
51
+ async def delete_session(self, session_id: str) -> None:
52
+ async with httpx.AsyncClient() as client:
53
+ r = await client.delete(
54
+ f"{self._base_url}/v1/sessions/{session_id}", headers=self._headers()
55
+ )
56
+ r.raise_for_status()
@@ -0,0 +1,255 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ from .api import HappyAPI
5
+ from .credentials import Credentials, load_credentials
6
+ from .encryption import (
7
+ decode_base64,
8
+ decrypt,
9
+ decrypt_box_bundle,
10
+ derive_content_key_pair,
11
+ encode_base64,
12
+ encrypt,
13
+ )
14
+ from .exceptions import HappyError, MachineOfflineError, SpawnError
15
+ from .session_connection import SessionConnection
16
+ from .socket_manager import SocketManager
17
+ from .types import Agent, Machine, Message, PermissionMode, Session
18
+
19
+
20
+ class HappyClient:
21
+ def __init__(
22
+ self,
23
+ server_url: str | None = None,
24
+ credentials_path: str | None = None,
25
+ ) -> None:
26
+ if server_url is None:
27
+ server_url = os.environ.get("HAPPY_SERVER_URL")
28
+ if not server_url:
29
+ raise ValueError("server_url is required (or set HAPPY_SERVER_URL)")
30
+
31
+ self._server_url = server_url.rstrip("/")
32
+ self._creds: Credentials = load_credentials(credentials_path)
33
+ self._api = HappyAPI(self._server_url, self._creds.token)
34
+ self._sockets = SocketManager(self._server_url, self._creds.token)
35
+ # Derive the NaCl content keypair once for decrypting dataEncryptionKey bundles
36
+ _pub, self._content_secret = derive_content_key_pair(self._creds.secret)
37
+
38
+ # -----------------------------------------------------------------------
39
+ # Key resolution
40
+ # -----------------------------------------------------------------------
41
+
42
+ async def _resolve_session_key(self, session_id: str) -> tuple[bytes, str]:
43
+ """Return (key_bytes, variant) for a session."""
44
+ raw = await self._api.get_session(session_id)
45
+ return self._extract_key(raw)
46
+
47
+ async def _resolve_machine_key(self, machine_id: str) -> tuple[bytes, str]:
48
+ """Return (key_bytes, variant) for a machine."""
49
+ machines = await self._api.list_machines()
50
+ for m in machines:
51
+ if m.get("id") == machine_id:
52
+ return self._extract_key(m)
53
+ raise MachineOfflineError(f"Machine {machine_id} not found")
54
+
55
+ def _extract_key(self, record: dict[str, Any]) -> tuple[bytes, str]:
56
+ dek = record.get("dataEncryptionKey")
57
+ if dek:
58
+ bundle = decode_base64(dek)
59
+ # Strip 1-byte version prefix if present
60
+ if len(bundle) > 56:
61
+ bundle = bundle[1:]
62
+ key = decrypt_box_bundle(bundle, self._content_secret)
63
+ if key is None:
64
+ from .exceptions import EncryptionError
65
+ raise EncryptionError("Failed to decrypt dataEncryptionKey")
66
+ return key, "dataKey"
67
+ return self._creds.secret, "legacy"
68
+
69
+ # -----------------------------------------------------------------------
70
+ # Machine operations
71
+ # -----------------------------------------------------------------------
72
+
73
+ async def list_machines(self, active_only: bool = False) -> list[Machine]:
74
+ raw = await self._api.list_machines()
75
+ machines = [
76
+ Machine(id=m["id"], active=m.get("active", False), metadata=m.get("metadata", {}))
77
+ for m in raw
78
+ ]
79
+ if active_only:
80
+ machines = [m for m in machines if m.active]
81
+ return machines
82
+
83
+ async def get_machine(self, machine_id: str) -> Machine:
84
+ machines = await self._api.list_machines()
85
+ for m in machines:
86
+ if m["id"] == machine_id:
87
+ return Machine(
88
+ id=m["id"], active=m.get("active", False), metadata=m.get("metadata", {})
89
+ )
90
+ raise HappyError(f"Machine {machine_id} not found")
91
+
92
+ # -----------------------------------------------------------------------
93
+ # Session lifecycle
94
+ # -----------------------------------------------------------------------
95
+
96
+ async def spawn_session(
97
+ self,
98
+ machine_id: str,
99
+ directory: str,
100
+ agent: Agent = "claude",
101
+ create_dir: bool = False,
102
+ ) -> str:
103
+ machine_key, machine_variant = await self._resolve_machine_key(machine_id)
104
+ payload = {
105
+ "type": "spawn-in-directory",
106
+ "directory": directory,
107
+ "approvedNewDirectoryCreation": create_dir,
108
+ "agent": agent,
109
+ }
110
+ encrypted = encrypt(machine_key, machine_variant, payload)
111
+ ack = await self._sockets.rpc_call(
112
+ f"{machine_id}:spawn-happy-session",
113
+ encode_base64(encrypted),
114
+ )
115
+ result_b64 = ack.get("result", "")
116
+ result = decrypt(machine_key, machine_variant, decode_base64(result_b64))
117
+ if not result or result.get("type") != "success":
118
+ msg = result.get("errorMessage", "unknown") if result else "decrypt failed"
119
+ raise SpawnError(f"Spawn failed: {msg}")
120
+ session_id = result.get("sessionId")
121
+ if not session_id:
122
+ raise SpawnError("Spawn succeeded but returned no sessionId")
123
+ return session_id
124
+
125
+ async def stop_session(self, session_id: str) -> None:
126
+ key, variant = await self._resolve_session_key(session_id)
127
+ sock = await self._sockets.open_session_socket(session_id)
128
+ conn = SessionConnection(sock, session_id, key, variant)
129
+ try:
130
+ await conn.send_stop()
131
+ finally:
132
+ await sock.disconnect()
133
+
134
+ async def delete_session(self, session_id: str) -> None:
135
+ await self._api.delete_session(session_id)
136
+
137
+ # -----------------------------------------------------------------------
138
+ # Messaging
139
+ # -----------------------------------------------------------------------
140
+
141
+ async def send_message(
142
+ self,
143
+ session_id: str,
144
+ text: str,
145
+ permission_mode: PermissionMode = "yolo",
146
+ ) -> None:
147
+ key, variant = await self._resolve_session_key(session_id)
148
+ sock = await self._sockets.open_session_socket(session_id)
149
+ conn = SessionConnection(sock, session_id, key, variant)
150
+ try:
151
+ await conn.send_message(text, permission_mode)
152
+ finally:
153
+ await sock.disconnect()
154
+
155
+ # -----------------------------------------------------------------------
156
+ # Waiting
157
+ # -----------------------------------------------------------------------
158
+
159
+ async def wait_for_turn_completion(
160
+ self, session_id: str, timeout_seconds: float = 300
161
+ ) -> None:
162
+ key, variant = await self._resolve_session_key(session_id)
163
+ sock = await self._sockets.open_session_socket(session_id)
164
+ conn = SessionConnection(sock, session_id, key, variant)
165
+ try:
166
+ await conn.wait_for_turn_completion(timeout_seconds)
167
+ finally:
168
+ await sock.disconnect()
169
+
170
+ async def wait_for_idle(
171
+ self, session_id: str, timeout_seconds: float = 300
172
+ ) -> None:
173
+ key, variant = await self._resolve_session_key(session_id)
174
+ sock = await self._sockets.open_session_socket(session_id)
175
+ conn = SessionConnection(sock, session_id, key, variant)
176
+ try:
177
+ await conn.wait_for_idle(timeout_seconds)
178
+ finally:
179
+ await sock.disconnect()
180
+
181
+ # -----------------------------------------------------------------------
182
+ # Query
183
+ # -----------------------------------------------------------------------
184
+
185
+ async def list_sessions(self, active_only: bool = False) -> list[Session]:
186
+ if active_only:
187
+ raw = await self._api.list_active_sessions()
188
+ else:
189
+ raw = await self._api.list_sessions()
190
+ return [
191
+ Session(
192
+ id=s["id"],
193
+ active=s.get("active", False),
194
+ created_at=s.get("createdAt", 0),
195
+ metadata=s.get("metadata", {}),
196
+ agent_state=s.get("agentState"),
197
+ )
198
+ for s in raw
199
+ ]
200
+
201
+ async def get_session(self, session_id: str) -> Session:
202
+ s = await self._api.get_session(session_id)
203
+ return Session(
204
+ id=s["id"],
205
+ active=s.get("active", False),
206
+ created_at=s.get("createdAt", 0),
207
+ metadata=s.get("metadata", {}),
208
+ agent_state=s.get("agentState"),
209
+ )
210
+
211
+ async def get_messages(self, session_id: str) -> list[Message]:
212
+ key, variant = await self._resolve_session_key(session_id)
213
+ raw = await self._api.get_messages(session_id)
214
+ messages = []
215
+ for m in raw:
216
+ content = m.get("content", {})
217
+ # Decrypt if still encrypted on the wire
218
+ if isinstance(content, dict) and content.get("t") == "encrypted":
219
+ c = content.get("c", "")
220
+ decrypted = decrypt(key, variant, decode_base64(c))
221
+ content = decrypted if decrypted is not None else content
222
+ messages.append(
223
+ Message(
224
+ id=m["id"],
225
+ seq=m.get("seq", 0),
226
+ content=content,
227
+ created_at=m.get("createdAt", 0),
228
+ )
229
+ )
230
+ return messages
231
+
232
+ # -----------------------------------------------------------------------
233
+ # Convenience
234
+ # -----------------------------------------------------------------------
235
+
236
+ async def run_task(
237
+ self,
238
+ machine_id: str,
239
+ directory: str,
240
+ prompt: str,
241
+ agent: Agent = "claude",
242
+ timeout_seconds: float = 600,
243
+ ) -> str:
244
+ session_id = await self.spawn_session(machine_id, directory, agent)
245
+ await self.send_message(session_id, prompt)
246
+ await self.wait_for_turn_completion(session_id, timeout_seconds)
247
+ await self.stop_session(session_id)
248
+ return session_id
249
+
250
+ # -----------------------------------------------------------------------
251
+ # Cleanup
252
+ # -----------------------------------------------------------------------
253
+
254
+ async def close(self) -> None:
255
+ pass # Connections are per-call; nothing persistent to close in v1
@@ -0,0 +1,44 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .exceptions import AuthenticationError
8
+
9
+
10
+ @dataclass
11
+ class Credentials:
12
+ token: str
13
+ secret: bytes
14
+
15
+
16
+ def load_credentials(path: str | None = None) -> Credentials:
17
+ if path is None:
18
+ home_dir = os.environ.get("HAPPY_HOME_DIR", str(Path.home() / ".happy"))
19
+ path = os.path.join(home_dir, "agent.key")
20
+
21
+ path = os.path.expanduser(path)
22
+ try:
23
+ with open(path) as f:
24
+ data = json.load(f)
25
+ except FileNotFoundError:
26
+ raise AuthenticationError(
27
+ f"Credentials file not found: {path}. Run 'happy-agent auth login' first."
28
+ )
29
+ except (json.JSONDecodeError, IOError) as e:
30
+ raise AuthenticationError(f"Failed to read credentials from {path}: {e}")
31
+
32
+ token = data.get("token")
33
+ secret_b64 = data.get("secret")
34
+ if not token or not secret_b64:
35
+ raise AuthenticationError(
36
+ f"Credentials file {path} is missing 'token' or 'secret' fields."
37
+ )
38
+
39
+ try:
40
+ secret = base64.b64decode(secret_b64)
41
+ except Exception as e:
42
+ raise AuthenticationError(f"Failed to decode secret from credentials: {e}")
43
+
44
+ return Credentials(token=token, secret=secret)