repr-cli 0.1.0__py3-none-any.whl → 0.2.2__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.
repr/__init__.py CHANGED
@@ -5,6 +5,6 @@ Analyzes your local git repositories and generates a compelling
5
5
  developer profile without ever sending your source code to the cloud.
6
6
  """
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.0"
9
9
  __author__ = "Repr"
10
10
  __email__ = "hello@repr.dev"
repr/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running repr as a module: python -m repr"""
2
+ from repr.cli import app
3
+
4
+ if __name__ == "__main__":
5
+ app()
6
+
repr/api.py CHANGED
@@ -23,6 +23,16 @@ def _get_user_url() -> str:
23
23
  return f"{get_api_base()}/user"
24
24
 
25
25
 
26
+ def _get_stories_url() -> str:
27
+ return f"{get_api_base()}/stories"
28
+
29
+
30
+ def _get_public_settings_url() -> str:
31
+ """Get URL for public profile settings endpoint."""
32
+ # This endpoint is under /api/public not /api/cli
33
+ return f"{get_api_base().replace('/api/cli', '/api/public')}/settings"
34
+
35
+
26
36
  class APIError(Exception):
27
37
  """API request error."""
28
38
  pass
@@ -34,7 +44,7 @@ def _get_headers() -> dict[str, str]:
34
44
  return {
35
45
  "Authorization": f"Bearer {token}",
36
46
  "Content-Type": "application/json",
37
- "User-Agent": "repr-cli/0.1.0",
47
+ "User-Agent": "repr-cli/0.1.1",
38
48
  }
39
49
 
40
50
 
@@ -261,3 +271,119 @@ def sync_get_user_info() -> dict[str, Any]:
261
271
  """
262
272
  import asyncio
263
273
  return asyncio.run(get_user_info())
274
+
275
+
276
+ async def get_stories(
277
+ repo_name: str | None = None,
278
+ since: str | None = None,
279
+ technologies: str | None = None,
280
+ limit: int = 50,
281
+ offset: int = 0,
282
+ ) -> dict[str, Any]:
283
+ """
284
+ Get commit stories from repr.dev.
285
+
286
+ Args:
287
+ repo_name: Filter by repository name
288
+ since: Filter by date (ISO format or human-readable like "1 week ago")
289
+ technologies: Comma-separated list of technologies to filter by
290
+ limit: Maximum number of stories to return
291
+ offset: Pagination offset
292
+
293
+ Returns:
294
+ Dict with 'stories' list and 'total' count
295
+
296
+ Raises:
297
+ APIError: If request fails
298
+ AuthError: If not authenticated
299
+ """
300
+ async with httpx.AsyncClient() as client:
301
+ try:
302
+ params: dict[str, Any] = {
303
+ "limit": limit,
304
+ "offset": offset,
305
+ }
306
+ if repo_name:
307
+ params["repo_name"] = repo_name
308
+ if since:
309
+ params["since"] = since
310
+ if technologies:
311
+ params["technologies"] = technologies
312
+
313
+ response = await client.get(
314
+ _get_stories_url(),
315
+ headers=_get_headers(),
316
+ params=params,
317
+ timeout=30,
318
+ )
319
+ response.raise_for_status()
320
+ return response.json()
321
+
322
+ except httpx.HTTPStatusError as e:
323
+ if e.response.status_code == 401:
324
+ raise AuthError("Session expired. Please run 'repr login' again.")
325
+ raise APIError(f"Failed to get stories: {e.response.status_code}")
326
+ except httpx.RequestError as e:
327
+ raise APIError(f"Network error: {str(e)}")
328
+
329
+
330
+ async def push_story(story_data: dict[str, Any]) -> dict[str, Any]:
331
+ """
332
+ Push a commit story to repr.dev.
333
+
334
+ Args:
335
+ story_data: Story data including summary, technologies, repo info, etc.
336
+
337
+ Returns:
338
+ Created/updated story data
339
+
340
+ Raises:
341
+ APIError: If request fails
342
+ AuthError: If not authenticated
343
+ """
344
+ async with httpx.AsyncClient() as client:
345
+ try:
346
+ response = await client.post(
347
+ _get_stories_url(),
348
+ headers=_get_headers(),
349
+ json=story_data,
350
+ timeout=60,
351
+ )
352
+ response.raise_for_status()
353
+ return response.json()
354
+
355
+ except httpx.HTTPStatusError as e:
356
+ if e.response.status_code == 401:
357
+ raise AuthError("Session expired. Please run 'repr login' again.")
358
+ raise APIError(f"Failed to push story: {e.response.status_code}")
359
+ except httpx.RequestError as e:
360
+ raise APIError(f"Network error: {str(e)}")
361
+
362
+
363
+ async def get_public_profile_settings() -> dict[str, Any]:
364
+ """
365
+ Get the current user's public profile settings.
366
+
367
+ Returns:
368
+ Dict with username, is_profile_public, profile_url, etc.
369
+
370
+ Raises:
371
+ APIError: If request fails
372
+ AuthError: If not authenticated
373
+ """
374
+ async with httpx.AsyncClient() as client:
375
+ try:
376
+ response = await client.get(
377
+ _get_public_settings_url(),
378
+ headers=_get_headers(),
379
+ timeout=30,
380
+ )
381
+ response.raise_for_status()
382
+ return response.json()
383
+
384
+ except httpx.HTTPStatusError as e:
385
+ if e.response.status_code == 401:
386
+ raise AuthError("Session expired. Please run 'repr login' again.")
387
+ raise APIError(f"Failed to get profile settings: {e.response.status_code}")
388
+ except httpx.RequestError as e:
389
+ raise APIError(f"Network error: {str(e)}")
repr/auth.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """
2
2
  Authentication via device code flow.
3
+
4
+ Tokens are stored securely in OS keychain (see keychain.py).
3
5
  """
4
6
 
5
7
  import asyncio
@@ -149,7 +151,9 @@ async def poll_for_token(device_code: str, interval: int = POLL_INTERVAL) -> Tok
149
151
 
150
152
  def save_token(token_response: TokenResponse) -> None:
151
153
  """
152
- Save authentication token to config.
154
+ Save authentication token securely.
155
+
156
+ Token is stored in OS keychain, config only stores a reference.
153
157
 
154
158
  Args:
155
159
  token_response: Token response from successful auth
@@ -163,7 +167,10 @@ def save_token(token_response: TokenResponse) -> None:
163
167
 
164
168
 
165
169
  def logout() -> None:
166
- """Clear authentication and logout."""
170
+ """Clear authentication and logout.
171
+
172
+ Removes token from keychain and clears config reference.
173
+ """
167
174
  clear_auth()
168
175
 
169
176
 
@@ -193,6 +200,63 @@ def require_auth() -> str:
193
200
  return auth["access_token"]
194
201
 
195
202
 
203
+ def migrate_plaintext_auth() -> bool:
204
+ """
205
+ Migrate plaintext auth tokens to keychain storage.
206
+
207
+ Called on first run after upgrade to detect and migrate old tokens.
208
+
209
+ Returns:
210
+ True if migration occurred
211
+ """
212
+ from .keychain import store_secret
213
+ import json
214
+ from .config import CONFIG_FILE, save_config
215
+
216
+ if not CONFIG_FILE.exists():
217
+ return False
218
+
219
+ try:
220
+ with open(CONFIG_FILE, "r") as f:
221
+ config = json.load(f)
222
+
223
+ auth = config.get("auth")
224
+ if not auth:
225
+ return False
226
+
227
+ # Check if already migrated (has keychain ref)
228
+ if auth.get("token_keychain_ref"):
229
+ return False
230
+
231
+ # Check for plaintext token
232
+ plaintext_token = auth.get("access_token")
233
+ if not plaintext_token:
234
+ return False
235
+
236
+ # Migrate to keychain
237
+ user_id = auth.get("user_id", "unknown")[:8]
238
+ keychain_ref = f"auth_token_{user_id}"
239
+ store_secret(keychain_ref, plaintext_token)
240
+
241
+ # Update config (remove plaintext, add reference)
242
+ del config["auth"]["access_token"]
243
+ config["auth"]["token_keychain_ref"] = keychain_ref
244
+
245
+ # Handle litellm key if present
246
+ litellm_key = auth.get("litellm_api_key")
247
+ if litellm_key:
248
+ litellm_ref = f"litellm_key_{user_id}"
249
+ store_secret(litellm_ref, litellm_key)
250
+ del config["auth"]["litellm_api_key"]
251
+ config["auth"]["litellm_keychain_ref"] = litellm_ref
252
+
253
+ save_config(config)
254
+ return True
255
+
256
+ except Exception:
257
+ return False
258
+
259
+
196
260
  class AuthFlow:
197
261
  """
198
262
  Manages the device code authentication flow with progress callbacks.