teammate-sync 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.
File without changes
teammate_sync/auth.py ADDED
@@ -0,0 +1,51 @@
1
+ """
2
+ Auth helper for teammate-sync clients (daemon + MCP).
3
+
4
+ Reads ~/.teammate-sync/auth.json, written by `teammate-sync init` (Phase 5c).
5
+ Format:
6
+ {
7
+ "token": "gho_...", # GitHub OAuth access token
8
+ "org": "SolarCheckr", # workspace = GitHub org name
9
+ "backend_url": "https://teammate-sync-backend.fly.dev"
10
+ }
11
+ """
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+
17
+ DEFAULT_AUTH_FILE = "~/.teammate-sync/auth.json"
18
+ DEFAULT_BACKEND_URL = "https://teammate-sync-backend.fly.dev"
19
+
20
+
21
+ def auth_file_path() -> Path:
22
+ return Path(os.environ.get("TEAMMATE_AUTH_FILE", DEFAULT_AUTH_FILE)).expanduser()
23
+
24
+
25
+ def read_auth() -> dict:
26
+ """Read the auth file. Raises with a clear message if missing or malformed."""
27
+ path = auth_file_path()
28
+ if not path.exists():
29
+ raise FileNotFoundError(
30
+ f"teammate-sync auth file not found at {path}.\n"
31
+ f"Run `teammate-sync init` to sign in with GitHub and create it."
32
+ )
33
+ try:
34
+ data = json.loads(path.read_text())
35
+ except json.JSONDecodeError as e:
36
+ raise ValueError(f"Auth file {path} is not valid JSON: {e}")
37
+ for required in ("token", "org"):
38
+ if not data.get(required):
39
+ raise ValueError(f"Auth file {path} missing required field: {required!r}")
40
+ data.setdefault("backend_url", DEFAULT_BACKEND_URL)
41
+ return data
42
+
43
+
44
+ def write_auth(token: str, org: str, backend_url: str = DEFAULT_BACKEND_URL) -> Path:
45
+ """Persist the auth file with restrictive permissions (0600)."""
46
+ path = auth_file_path()
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ payload = {"token": token, "org": org, "backend_url": backend_url}
49
+ path.write_text(json.dumps(payload, indent=2))
50
+ path.chmod(0o600)
51
+ return path
@@ -0,0 +1,375 @@
1
+ """
2
+ Storage backend abstraction for teammate-sync.
3
+
4
+ Three implementations:
5
+ - LocalBackend — writes to a local filesystem directory (dev / tests)
6
+ - S3Backend — direct-to-S3 (legacy from Phase 3a, kept for hosted-self-deploy)
7
+ - HTTPBackend — calls the teammate-sync cloud backend (Phase 5+, the default)
8
+
9
+ Selected via env var TEAMMATE_BACKEND (local|s3|cloud). The daemon and MCP
10
+ server both construct the backend the same way, so they always agree.
11
+
12
+ The interface is intentionally minimal — just key/value bytes — so adding
13
+ a new backend later (R2, GCS, etc.) is a small isolated change.
14
+ """
15
+ import json
16
+ import os
17
+ import time
18
+ from abc import ABC, abstractmethod
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+
23
+ SYNC_STATE_FILENAME = ".sync-state.json"
24
+ ACTIVE_SESSIONS_FILENAME = ".active-sessions.json"
25
+ SHARED_SESSIONS_FILENAME = ".shared-sessions.json"
26
+
27
+ # Control files are managed by the system (daemon writes sync state, hooks
28
+ # write active sessions, share-cli writes shared sessions). They're
29
+ # addressable via get_bytes/put_bytes but omitted from list_keys so the MCP
30
+ # doesn't try to render them as corpus content. The daemon also never
31
+ # uploads .shared-sessions.json to the backend — it's a local-only
32
+ # permission gate.
33
+ CONTROL_FILES = {
34
+ SYNC_STATE_FILENAME,
35
+ ACTIVE_SESSIONS_FILENAME,
36
+ SHARED_SESSIONS_FILENAME,
37
+ }
38
+ # Transient/process-local files that should never appear in the corpus
39
+ # (fcntl lock files, atomic-write tempfiles).
40
+ SKIP_SUFFIXES = (".lock", ".tmp")
41
+
42
+
43
+ class StorageBackend(ABC):
44
+ """Abstract storage backend for mirroring a teammate's working corpus."""
45
+
46
+ @abstractmethod
47
+ def list_keys(self) -> list[str]:
48
+ """List all keys (relative paths) in the backend, excluding the state file."""
49
+
50
+ @abstractmethod
51
+ def get_bytes(self, key: str) -> bytes | None:
52
+ """Read bytes at key. Returns None if missing."""
53
+
54
+ @abstractmethod
55
+ def put_bytes(self, key: str, data: bytes) -> None:
56
+ """Write bytes at key."""
57
+
58
+ @abstractmethod
59
+ def delete_key(self, key: str) -> None:
60
+ """Delete the object at key. No-op if missing."""
61
+
62
+ def get_state(self) -> dict | None:
63
+ raw = self.get_bytes(SYNC_STATE_FILENAME)
64
+ if raw is None:
65
+ return None
66
+ try:
67
+ return json.loads(raw.decode("utf-8"))
68
+ except (json.JSONDecodeError, UnicodeDecodeError):
69
+ return None
70
+
71
+ def put_state(self) -> None:
72
+ state = {
73
+ "last_sync_iso": datetime.now(timezone.utc).isoformat(),
74
+ "last_sync_epoch": time.time(),
75
+ }
76
+ self.put_bytes(
77
+ SYNC_STATE_FILENAME,
78
+ json.dumps(state, indent=2).encode("utf-8"),
79
+ )
80
+
81
+
82
+ class LocalBackend(StorageBackend):
83
+ def __init__(self, target_dir: str | Path):
84
+ self.target = Path(target_dir).expanduser().resolve()
85
+ self.target.mkdir(parents=True, exist_ok=True)
86
+
87
+ def list_keys(self) -> list[str]:
88
+ keys: list[str] = []
89
+ for f in self.target.rglob("*"):
90
+ if not f.is_file():
91
+ continue
92
+ if f.name in CONTROL_FILES or f.name.endswith(SKIP_SUFFIXES):
93
+ continue
94
+ keys.append(str(f.relative_to(self.target)))
95
+ return sorted(keys)
96
+
97
+ def get_bytes(self, key: str) -> bytes | None:
98
+ path = self.target / key
99
+ if not path.exists():
100
+ return None
101
+ return path.read_bytes()
102
+
103
+ def put_bytes(self, key: str, data: bytes) -> None:
104
+ path = self.target / key
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ path.write_bytes(data)
107
+
108
+ def delete_key(self, key: str) -> None:
109
+ path = self.target / key
110
+ if path.exists():
111
+ path.unlink()
112
+
113
+ def __repr__(self) -> str:
114
+ return f"LocalBackend(target={self.target})"
115
+
116
+
117
+ class S3Backend(StorageBackend):
118
+ def __init__(self, bucket: str, prefix: str = "", region: str | None = None):
119
+ import boto3 # lazy import — only required if S3 backend is in use
120
+
121
+ self.bucket = bucket
122
+ # Normalize prefix: no leading "/", always trailing "/" (or empty)
123
+ normalized = prefix.lstrip("/").rstrip("/")
124
+ self.prefix = (normalized + "/") if normalized else ""
125
+ self._region = region
126
+ self.s3 = boto3.client("s3", region_name=region)
127
+
128
+ def _key(self, key: str) -> str:
129
+ return self.prefix + key
130
+
131
+ def list_keys(self) -> list[str]:
132
+ keys: list[str] = []
133
+ paginator = self.s3.get_paginator("list_objects_v2")
134
+ kwargs = {"Bucket": self.bucket}
135
+ if self.prefix:
136
+ kwargs["Prefix"] = self.prefix
137
+ for page in paginator.paginate(**kwargs):
138
+ for obj in page.get("Contents", []) or []:
139
+ full_key = obj["Key"]
140
+ rel = full_key[len(self.prefix):] if self.prefix else full_key
141
+ if rel in CONTROL_FILES or rel == "" or rel.endswith(SKIP_SUFFIXES):
142
+ continue
143
+ keys.append(rel)
144
+ return sorted(keys)
145
+
146
+ def get_bytes(self, key: str) -> bytes | None:
147
+ from botocore.exceptions import ClientError
148
+
149
+ try:
150
+ resp = self.s3.get_object(Bucket=self.bucket, Key=self._key(key))
151
+ return resp["Body"].read()
152
+ except ClientError as e:
153
+ if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
154
+ return None
155
+ raise
156
+
157
+ def put_bytes(self, key: str, data: bytes) -> None:
158
+ self.s3.put_object(Bucket=self.bucket, Key=self._key(key), Body=data)
159
+
160
+ def delete_key(self, key: str) -> None:
161
+ # delete_object is idempotent
162
+ self.s3.delete_object(Bucket=self.bucket, Key=self._key(key))
163
+
164
+ def __repr__(self) -> str:
165
+ return f"S3Backend(bucket={self.bucket}, prefix={self.prefix!r}, region={self._region})"
166
+
167
+
168
+ class HTTPBackend(StorageBackend):
169
+ """
170
+ Storage backend that talks to the teammate-sync cloud backend over HTTP.
171
+
172
+ DAEMON use: teammate=<my own github handle>, since daemon writes its own
173
+ files. The backend authoritatively binds the owner to the auth token,
174
+ so wrong values get rejected — but we still need it right for the
175
+ list/delete paths which take teammate as a parameter.
176
+
177
+ MCP use: teammate=<the queried engineer>. Reads scoped to that teammate.
178
+ Writes would 403 (owner mismatch) — which is correct, MCP never writes.
179
+
180
+ All requests authenticated with a GitHub OAuth access token via Bearer.
181
+ """
182
+
183
+ def __init__(self, backend_url: str, token: str, org: str, teammate: str):
184
+ import httpx # lazy
185
+
186
+ self.backend_url = backend_url.rstrip("/")
187
+ self.org = org
188
+ self.teammate = teammate
189
+ self._token = token
190
+ self._client = httpx.Client(
191
+ base_url=self.backend_url,
192
+ headers={"Authorization": f"Bearer {token}"},
193
+ timeout=30.0,
194
+ )
195
+
196
+ def _basename(self, key: str) -> str:
197
+ return Path(key).name
198
+
199
+ def list_keys(self) -> list[str]:
200
+ r = self._client.get(
201
+ "/v1/files",
202
+ params={"org": self.org, "teammate": self.teammate},
203
+ )
204
+ r.raise_for_status()
205
+ keys = [f["path"] for f in r.json().get("files", [])]
206
+ # Filter control + skip-suffix files client-side (same semantics as
207
+ # LocalBackend and S3Backend).
208
+ return [
209
+ k for k in keys
210
+ if self._basename(k) not in CONTROL_FILES
211
+ and not self._basename(k).endswith(SKIP_SUFFIXES)
212
+ ]
213
+
214
+ def get_bytes(self, key: str) -> bytes | None:
215
+ r = self._client.get(
216
+ "/v1/files/get",
217
+ params={"org": self.org, "teammate": self.teammate, "path": key},
218
+ )
219
+ if r.status_code == 404:
220
+ return None
221
+ r.raise_for_status()
222
+ return r.content
223
+
224
+ def put_bytes(self, key: str, data: bytes) -> None:
225
+ import base64
226
+
227
+ r = self._client.post(
228
+ "/v1/files",
229
+ json={
230
+ "org": self.org,
231
+ "path": key,
232
+ "content_b64": base64.b64encode(data).decode("ascii"),
233
+ },
234
+ )
235
+ r.raise_for_status()
236
+
237
+ def delete_key(self, key: str) -> None:
238
+ r = self._client.delete(
239
+ "/v1/files",
240
+ params={"org": self.org, "path": key},
241
+ )
242
+ if r.status_code != 404:
243
+ r.raise_for_status()
244
+
245
+ def get_state(self) -> dict | None:
246
+ r = self._client.get(
247
+ "/v1/state",
248
+ params={"org": self.org, "teammate": self.teammate},
249
+ )
250
+ r.raise_for_status()
251
+ data = r.json()
252
+ epoch = data.get("last_sync_epoch")
253
+ if not isinstance(epoch, (int, float)):
254
+ return None
255
+ return {"last_sync_epoch": epoch, "last_sync_iso": ""}
256
+
257
+ def put_state(self) -> None:
258
+ r = self._client.post("/v1/state", params={"org": self.org})
259
+ r.raise_for_status()
260
+
261
+ def purge_owner(self) -> int:
262
+ """
263
+ Single-call optimization: delete every file the caller owns in this
264
+ workspace. Used by the daemon's cleanup when share-mode flips off.
265
+ """
266
+ r = self._client.delete("/v1/files/purge", params={"org": self.org})
267
+ r.raise_for_status()
268
+ return r.json().get("deleted", 0)
269
+
270
+ def __repr__(self) -> str:
271
+ return f"HTTPBackend(url={self.backend_url}, org={self.org}, teammate={self.teammate})"
272
+
273
+
274
+ def make_backend_from_env() -> StorageBackend:
275
+ """
276
+ Construct the WRITER backend based on env vars. Used by the daemon to
277
+ decide where its own teammate's content goes.
278
+
279
+ Backend selection via TEAMMATE_BACKEND:
280
+ - "cloud" (DEFAULT): talks to teammate-sync cloud backend. Reads auth
281
+ from ~/.teammate-sync/auth.json (see auth.py).
282
+ - "s3": legacy direct-to-S3 (set TEAMMATE_S3_BUCKET + TEAMMATE_HANDLE).
283
+ - "local": local filesystem (set TEAMMATE_CORPUS_DIR).
284
+ """
285
+ import httpx # lazy
286
+
287
+ backend = os.environ.get("TEAMMATE_BACKEND", "cloud").lower()
288
+
289
+ if backend == "local":
290
+ target = os.environ.get("TEAMMATE_CORPUS_DIR", "./example_data")
291
+ return LocalBackend(target)
292
+
293
+ if backend == "s3":
294
+ bucket = os.environ.get("TEAMMATE_S3_BUCKET")
295
+ if not bucket:
296
+ raise ValueError("TEAMMATE_S3_BUCKET must be set when TEAMMATE_BACKEND=s3")
297
+ handle = os.environ.get("TEAMMATE_HANDLE")
298
+ explicit_prefix = os.environ.get("TEAMMATE_S3_PREFIX")
299
+ if handle:
300
+ prefix = handle.strip("/") + "/"
301
+ elif explicit_prefix is not None:
302
+ prefix = explicit_prefix
303
+ else:
304
+ raise ValueError(
305
+ "Either TEAMMATE_HANDLE (preferred) or TEAMMATE_S3_PREFIX "
306
+ "must be set when TEAMMATE_BACKEND=s3"
307
+ )
308
+ region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
309
+ return S3Backend(bucket=bucket, prefix=prefix, region=region)
310
+
311
+ if backend == "cloud":
312
+ from auth import read_auth
313
+
314
+ auth = read_auth() # raises with a clear message if missing
315
+ # Resolve our own GitHub handle so the daemon's list/delete calls
316
+ # target our own corpus (writes are owner-bound by the backend anyway).
317
+ r = httpx.get(
318
+ f"{auth['backend_url'].rstrip('/')}/v1/me",
319
+ headers={"Authorization": f"Bearer {auth['token']}"},
320
+ timeout=10.0,
321
+ )
322
+ if r.status_code != 200:
323
+ raise ValueError(
324
+ f"Cloud backend rejected token (/v1/me → {r.status_code}). "
325
+ f"Re-run `teammate-sync init` to refresh."
326
+ )
327
+ self_handle = r.json()["github_handle"]
328
+ return HTTPBackend(
329
+ backend_url=auth["backend_url"],
330
+ token=auth["token"],
331
+ org=auth["org"],
332
+ teammate=self_handle,
333
+ )
334
+
335
+ raise ValueError(f"Unknown TEAMMATE_BACKEND: {backend!r}. Use 'cloud', 's3', or 'local'.")
336
+
337
+
338
+ def make_s3_backend_for(handle: str) -> S3Backend:
339
+ """
340
+ Construct a READER backend for a specific teammate handle. Used by the
341
+ MCP server to query any teammate by parameter.
342
+
343
+ Reads bucket + region from env (TEAMMATE_S3_BUCKET, AWS_REGION) and
344
+ composes the prefix from the handle.
345
+ """
346
+ bucket = os.environ.get("TEAMMATE_S3_BUCKET")
347
+ if not bucket:
348
+ raise ValueError("TEAMMATE_S3_BUCKET must be set")
349
+ region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
350
+ prefix = handle.strip("/") + "/"
351
+ return S3Backend(bucket=bucket, prefix=prefix, region=region)
352
+
353
+
354
+ def list_s3_teammates() -> list[str]:
355
+ """
356
+ Discover available teammates by listing top-level "directories" in the
357
+ configured S3 bucket. Used by the MCP server's list_teammates tool.
358
+ Returns handles without trailing slash.
359
+ """
360
+ import boto3
361
+
362
+ bucket = os.environ.get("TEAMMATE_S3_BUCKET")
363
+ if not bucket:
364
+ raise ValueError("TEAMMATE_S3_BUCKET must be set")
365
+ region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
366
+ s3 = boto3.client("s3", region_name=region)
367
+
368
+ handles: list[str] = []
369
+ paginator = s3.get_paginator("list_objects_v2")
370
+ for page in paginator.paginate(Bucket=bucket, Delimiter="/"):
371
+ for entry in page.get("CommonPrefixes", []) or []:
372
+ p = entry.get("Prefix", "").rstrip("/")
373
+ if p:
374
+ handles.append(p)
375
+ return sorted(handles)