quickcall-integrations 0.1.8__py3-none-any.whl → 0.2.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.
@@ -3,13 +3,17 @@ Credential storage and management for QuickCall MCP.
3
3
 
4
4
  Stores device tokens locally in ~/.quickcall/credentials.json
5
5
  Fetches fresh API credentials from quickcall.dev on demand.
6
+
7
+ Also supports GitHub PAT fallback for users without GitHub App access:
8
+ - Environment variable: GITHUB_TOKEN or GITHUB_PAT
9
+ - Config file: .quickcall.env in project root or ~/.quickcall.env
6
10
  """
7
11
 
8
12
  import os
9
13
  import json
10
14
  import logging
11
15
  from pathlib import Path
12
- from typing import Optional, Dict, Any
16
+ from typing import Optional, Dict, Any, Tuple
13
17
  from dataclasses import dataclass, asdict
14
18
  from datetime import datetime
15
19
 
@@ -21,6 +25,9 @@ logger = logging.getLogger(__name__)
21
25
  QUICKCALL_DIR = Path.home() / ".quickcall"
22
26
  CREDENTIALS_FILE = QUICKCALL_DIR / "credentials.json"
23
27
 
28
+ # PAT config file names (searched in order)
29
+ PAT_CONFIG_FILENAMES = [".quickcall.env", "quickcall.env"]
30
+
24
31
  # QuickCall API - configurable via environment for local testing
25
32
  # Set QUICKCALL_API_URL=http://localhost:8000 for local development
26
33
  QUICKCALL_API_URL = os.getenv("QUICKCALL_API_URL", "https://api.quickcall.dev")
@@ -50,6 +57,26 @@ class StoredCredentials:
50
57
  )
51
58
 
52
59
 
60
+ @dataclass
61
+ class GitHubPATCredentials:
62
+ """GitHub PAT credentials stored locally (independent of QuickCall)."""
63
+
64
+ token: str # ghp_xxx or github_pat_xxx
65
+ username: str
66
+ configured_at: str
67
+
68
+ def to_dict(self) -> Dict[str, Any]:
69
+ return asdict(self)
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: Dict[str, Any]) -> "GitHubPATCredentials":
73
+ return cls(
74
+ token=data["token"],
75
+ username=data["username"],
76
+ configured_at=data["configured_at"],
77
+ )
78
+
79
+
53
80
  @dataclass
54
81
  class APICredentials:
55
82
  """Fresh credentials fetched from QuickCall API."""
@@ -77,21 +104,26 @@ class CredentialStore:
77
104
  """
78
105
  Manages credential storage and retrieval.
79
106
 
107
+ Supports two independent credential types:
108
+ 1. QuickCall credentials (device token for API auth)
109
+ 2. GitHub PAT credentials (for users without GitHub App access)
110
+
80
111
  Usage:
81
112
  store = CredentialStore()
82
113
 
83
- # Check if authenticated
114
+ # QuickCall auth
84
115
  if store.is_authenticated():
85
116
  creds = store.get_api_credentials()
86
- if creds.github_connected:
87
- # Use GitHub token
88
- pass
89
117
 
90
- # Save after device flow
91
- store.save(StoredCredentials(device_token="qt_xxx", user_id="user_xxx"))
118
+ # GitHub PAT auth
119
+ pat_creds = store.get_github_pat_credentials()
120
+ if pat_creds:
121
+ # Use PAT
122
+ pass
92
123
 
93
- # Clear on logout
94
- store.clear()
124
+ # Save credentials
125
+ store.save(StoredCredentials(...)) # QuickCall
126
+ store.save_github_pat(token="ghp_xxx", username="user") # PAT
95
127
  """
96
128
 
97
129
  def __init__(self, api_url: Optional[str] = None):
@@ -103,6 +135,7 @@ class CredentialStore:
103
135
  """
104
136
  self.api_url = api_url or QUICKCALL_API_URL
105
137
  self._stored: Optional[StoredCredentials] = None
138
+ self._github_pat: Optional[GitHubPATCredentials] = None
106
139
  self._api_creds: Optional[APICredentials] = None
107
140
  self._load()
108
141
 
@@ -114,47 +147,127 @@ class CredentialStore:
114
147
  try:
115
148
  with open(CREDENTIALS_FILE) as f:
116
149
  data = json.load(f)
117
- self._stored = StoredCredentials.from_dict(data)
118
- logger.debug(f"Loaded credentials for user {self._stored.user_id}")
150
+
151
+ # New format: separate keys for quickcall and github_pat
152
+ if "quickcall" in data:
153
+ self._stored = StoredCredentials.from_dict(data["quickcall"])
154
+ logger.debug(
155
+ f"Loaded QuickCall credentials for user {self._stored.user_id}"
156
+ )
157
+ elif "device_token" in data:
158
+ # Legacy format: direct StoredCredentials
159
+ self._stored = StoredCredentials.from_dict(data)
160
+ logger.debug(
161
+ f"Loaded legacy credentials for user {self._stored.user_id}"
162
+ )
163
+
164
+ # Load GitHub PAT if present
165
+ if "github_pat" in data:
166
+ self._github_pat = GitHubPATCredentials.from_dict(
167
+ data["github_pat"]
168
+ )
169
+ logger.debug(
170
+ f"Loaded GitHub PAT for user {self._github_pat.username}"
171
+ )
172
+
119
173
  except Exception as e:
120
174
  logger.warning(f"Failed to load credentials: {e}")
121
175
 
122
- def save(self, credentials: StoredCredentials):
123
- """Save credentials to disk."""
176
+ def _save_to_file(self):
177
+ """Save all credentials to disk."""
124
178
  QUICKCALL_DIR.mkdir(parents=True, exist_ok=True)
125
179
 
180
+ data = {}
181
+ if self._stored:
182
+ data["quickcall"] = self._stored.to_dict()
183
+ if self._github_pat:
184
+ data["github_pat"] = self._github_pat.to_dict()
185
+
126
186
  try:
127
187
  with open(CREDENTIALS_FILE, "w") as f:
128
- json.dump(credentials.to_dict(), f, indent=2)
188
+ json.dump(data, f, indent=2)
129
189
  CREDENTIALS_FILE.chmod(0o600) # Restrict permissions
130
- self._stored = credentials
131
- self._api_creds = None # Clear cached API creds
132
- logger.info(f"Saved credentials for user {credentials.user_id}")
190
+ logger.debug("Saved credentials to disk")
133
191
  except Exception as e:
134
192
  logger.error(f"Failed to save credentials: {e}")
135
193
  raise
136
194
 
195
+ def save(self, credentials: StoredCredentials):
196
+ """Save QuickCall credentials to disk."""
197
+ self._stored = credentials
198
+ self._api_creds = None # Clear cached API creds
199
+ self._save_to_file()
200
+ logger.info(f"Saved QuickCall credentials for user {credentials.user_id}")
201
+
137
202
  def clear(self):
138
- """Clear stored credentials."""
203
+ """Clear all stored credentials (QuickCall + PAT)."""
139
204
  if CREDENTIALS_FILE.exists():
140
205
  try:
141
206
  CREDENTIALS_FILE.unlink()
142
- logger.info("Cleared stored credentials")
207
+ logger.info("Cleared all stored credentials")
143
208
  except Exception as e:
144
209
  logger.error(f"Failed to clear credentials: {e}")
145
210
  raise
146
211
 
147
212
  self._stored = None
213
+ self._github_pat = None
148
214
  self._api_creds = None
149
215
 
216
+ def clear_quickcall(self):
217
+ """Clear only QuickCall credentials, keep PAT if configured."""
218
+ self._stored = None
219
+ self._api_creds = None
220
+ if self._github_pat:
221
+ self._save_to_file()
222
+ elif CREDENTIALS_FILE.exists():
223
+ CREDENTIALS_FILE.unlink()
224
+ logger.info("Cleared QuickCall credentials")
225
+
150
226
  def is_authenticated(self) -> bool:
151
- """Check if we have stored credentials."""
227
+ """Check if we have QuickCall credentials."""
152
228
  return self._stored is not None
153
229
 
154
230
  def get_stored_credentials(self) -> Optional[StoredCredentials]:
155
- """Get locally stored credentials (device token, etc)."""
231
+ """Get locally stored QuickCall credentials (device token, etc)."""
156
232
  return self._stored
157
233
 
234
+ # ========================================================================
235
+ # GitHub PAT Methods
236
+ # ========================================================================
237
+
238
+ def save_github_pat(self, token: str, username: str):
239
+ """
240
+ Save GitHub PAT credentials.
241
+
242
+ Args:
243
+ token: GitHub Personal Access Token
244
+ username: GitHub username
245
+ """
246
+ self._github_pat = GitHubPATCredentials(
247
+ token=token,
248
+ username=username,
249
+ configured_at=datetime.utcnow().isoformat() + "Z",
250
+ )
251
+ self._save_to_file()
252
+ logger.info(f"Saved GitHub PAT for user {username}")
253
+
254
+ def get_github_pat_credentials(self) -> Optional[GitHubPATCredentials]:
255
+ """Get stored GitHub PAT credentials."""
256
+ return self._github_pat
257
+
258
+ def clear_github_pat(self):
259
+ """Clear only GitHub PAT credentials, keep QuickCall if configured."""
260
+ self._github_pat = None
261
+ if self._stored:
262
+ self._save_to_file()
263
+ elif CREDENTIALS_FILE.exists():
264
+ CREDENTIALS_FILE.unlink()
265
+ logger.info("Cleared GitHub PAT credentials")
266
+
267
+ def has_github_pat(self) -> bool:
268
+ """Check if GitHub PAT is configured."""
269
+ return self._github_pat is not None
270
+
158
271
  def get_api_credentials(
159
272
  self, force_refresh: bool = False
160
273
  ) -> Optional[APICredentials]:
@@ -223,32 +336,56 @@ class CredentialStore:
223
336
 
224
337
  def get_status(self) -> Dict[str, Any]:
225
338
  """Get authentication status for diagnostics."""
226
- if not self._stored:
227
- return {
228
- "authenticated": False,
229
- "credentials_file": str(CREDENTIALS_FILE),
230
- "file_exists": CREDENTIALS_FILE.exists(),
231
- }
232
-
233
- # Always fetch fresh status (force refresh to get latest connection states)
234
- api_creds = self.get_api_credentials(force_refresh=True)
235
-
236
- return {
237
- "authenticated": True,
339
+ result = {
238
340
  "credentials_file": str(CREDENTIALS_FILE),
239
- "user_id": self._stored.user_id,
240
- "email": self._stored.email,
241
- "username": self._stored.username,
242
- "authenticated_at": self._stored.authenticated_at,
243
- "github": {
341
+ "file_exists": CREDENTIALS_FILE.exists(),
342
+ }
343
+
344
+ # QuickCall status
345
+ if self._stored:
346
+ result["quickcall_authenticated"] = True
347
+ result["user_id"] = self._stored.user_id
348
+ result["email"] = self._stored.email
349
+ result["username"] = self._stored.username
350
+ result["authenticated_at"] = self._stored.authenticated_at
351
+
352
+ # Fetch fresh API credentials for integration status
353
+ api_creds = self.get_api_credentials(force_refresh=True)
354
+ result["github"] = {
244
355
  "connected": api_creds.github_connected if api_creds else False,
356
+ "mode": "github_app"
357
+ if (api_creds and api_creds.github_connected)
358
+ else None,
245
359
  "username": api_creds.github_username if api_creds else None,
246
- },
247
- "slack": {
360
+ }
361
+ result["slack"] = {
248
362
  "connected": api_creds.slack_connected if api_creds else False,
249
363
  "team_name": api_creds.slack_team_name if api_creds else None,
250
- },
251
- }
364
+ }
365
+ else:
366
+ result["quickcall_authenticated"] = False
367
+ result["github"] = {"connected": False, "mode": None, "username": None}
368
+ result["slack"] = {"connected": False, "team_name": None}
369
+
370
+ # GitHub PAT status (independent of QuickCall)
371
+ if self._github_pat:
372
+ result["github_pat"] = {
373
+ "configured": True,
374
+ "username": self._github_pat.username,
375
+ "configured_at": self._github_pat.configured_at,
376
+ }
377
+ # If QuickCall GitHub not connected, PAT takes over
378
+ if not result["github"]["connected"]:
379
+ result["github"]["connected"] = True
380
+ result["github"]["mode"] = "pat"
381
+ result["github"]["username"] = self._github_pat.username
382
+ else:
383
+ result["github_pat"] = {"configured": False}
384
+
385
+ # Legacy compatibility
386
+ result["authenticated"] = result["quickcall_authenticated"]
387
+
388
+ return result
252
389
 
253
390
 
254
391
  # Global credential store instance
@@ -276,3 +413,174 @@ def get_credentials() -> Optional[APICredentials]:
276
413
  def clear_credentials():
277
414
  """Clear stored credentials (logout)."""
278
415
  get_credential_store().clear()
416
+
417
+
418
+ # ============================================================================
419
+ # GitHub PAT Fallback Support
420
+ # ============================================================================
421
+
422
+
423
+ def _parse_env_file(file_path: Path) -> Dict[str, str]:
424
+ """
425
+ Parse a simple .env file into a dictionary.
426
+
427
+ Supports:
428
+ - KEY=value
429
+ - KEY="value"
430
+ - KEY='value'
431
+ - # comments
432
+ - Empty lines
433
+ """
434
+ result = {}
435
+ if not file_path.exists():
436
+ return result
437
+
438
+ try:
439
+ with open(file_path) as f:
440
+ for line in f:
441
+ line = line.strip()
442
+ # Skip empty lines and comments
443
+ if not line or line.startswith("#"):
444
+ continue
445
+
446
+ # Parse KEY=value
447
+ if "=" in line:
448
+ key, _, value = line.partition("=")
449
+ key = key.strip()
450
+ value = value.strip()
451
+
452
+ # Remove surrounding quotes
453
+ if (value.startswith('"') and value.endswith('"')) or (
454
+ value.startswith("'") and value.endswith("'")
455
+ ):
456
+ value = value[1:-1]
457
+
458
+ result[key] = value
459
+ except Exception as e:
460
+ logger.debug(f"Failed to parse {file_path}: {e}")
461
+
462
+ return result
463
+
464
+
465
+ def _find_project_root() -> Optional[Path]:
466
+ """
467
+ Find the project root by looking for common markers.
468
+
469
+ Walks up from cwd looking for .git, pyproject.toml, package.json, etc.
470
+ Returns None if no project root is found.
471
+ """
472
+ markers = [".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"]
473
+
474
+ try:
475
+ current = Path.cwd().resolve()
476
+ except Exception:
477
+ return None
478
+
479
+ # Walk up the directory tree
480
+ while current != current.parent:
481
+ for marker in markers:
482
+ if (current / marker).exists():
483
+ return current
484
+ current = current.parent
485
+
486
+ return None
487
+
488
+
489
+ def get_github_pat() -> Tuple[Optional[str], Optional[str]]:
490
+ """
491
+ Get GitHub Personal Access Token from various sources.
492
+
493
+ Search order (first found wins):
494
+ 1. Stored credentials file (via connect_github_via_pat command)
495
+ 2. Environment variable: GITHUB_TOKEN
496
+ 3. Environment variable: GITHUB_PAT
497
+ 4. Project root: .quickcall.env or quickcall.env
498
+ 5. Home directory: ~/.quickcall.env or ~/quickcall.env
499
+
500
+ Returns:
501
+ Tuple of (token, source) where source describes where the token was found.
502
+ Returns (None, None) if no PAT is configured.
503
+ """
504
+ # 1. Check stored credentials (from connect_github_via_pat command)
505
+ store = get_credential_store()
506
+ pat_creds = store.get_github_pat_credentials()
507
+ if pat_creds:
508
+ logger.debug("Found GitHub PAT in stored credentials")
509
+ return (pat_creds.token, "credentials file")
510
+
511
+ # 2. Check environment variables
512
+ for env_var in ["GITHUB_TOKEN", "GITHUB_PAT"]:
513
+ token = os.environ.get(env_var)
514
+ if token:
515
+ logger.debug(f"Found GitHub PAT in environment variable {env_var}")
516
+ return (token, f"environment variable {env_var}")
517
+
518
+ # 3. Check project root config files
519
+ project_root = _find_project_root()
520
+ if project_root:
521
+ for filename in PAT_CONFIG_FILENAMES:
522
+ config_path = project_root / filename
523
+ if config_path.exists():
524
+ env_vars = _parse_env_file(config_path)
525
+ for key in ["GITHUB_TOKEN", "GITHUB_PAT"]:
526
+ if key in env_vars:
527
+ logger.debug(f"Found GitHub PAT in {config_path}")
528
+ return (env_vars[key], f"{config_path}")
529
+
530
+ # 4. Check home directory config files
531
+ home = Path.home()
532
+ for filename in PAT_CONFIG_FILENAMES:
533
+ config_path = home / filename
534
+ if config_path.exists():
535
+ env_vars = _parse_env_file(config_path)
536
+ for key in ["GITHUB_TOKEN", "GITHUB_PAT"]:
537
+ if key in env_vars:
538
+ logger.debug(f"Found GitHub PAT in {config_path}")
539
+ return (env_vars[key], f"{config_path}")
540
+
541
+ return (None, None)
542
+
543
+
544
+ def get_github_pat_username() -> Optional[str]:
545
+ """
546
+ Get the GitHub username for PAT authentication.
547
+
548
+ Checks:
549
+ 1. Stored credentials (from connect_github_via_pat command)
550
+ 2. Environment variable: GITHUB_USERNAME
551
+ 3. Config files (same search order as get_github_pat)
552
+
553
+ Returns:
554
+ GitHub username or None if not configured.
555
+ """
556
+ # Check stored credentials first
557
+ store = get_credential_store()
558
+ pat_creds = store.get_github_pat_credentials()
559
+ if pat_creds:
560
+ return pat_creds.username
561
+
562
+ # Check environment variable
563
+ username = os.environ.get("GITHUB_USERNAME")
564
+ if username:
565
+ return username
566
+
567
+ # Check project root config files
568
+ project_root = _find_project_root()
569
+ if project_root:
570
+ for filename in PAT_CONFIG_FILENAMES:
571
+ config_path = project_root / filename
572
+ if config_path.exists():
573
+ env_vars = _parse_env_file(config_path)
574
+ if "GITHUB_USERNAME" in env_vars:
575
+ return env_vars["GITHUB_USERNAME"]
576
+
577
+ # Check home directory config files
578
+ home = Path.home()
579
+ for filename in PAT_CONFIG_FILENAMES:
580
+ config_path = home / filename
581
+ if config_path.exists():
582
+ env_vars = _parse_env_file(config_path)
583
+ if "GITHUB_USERNAME" in env_vars:
584
+ return env_vars["GITHUB_USERNAME"]
585
+
586
+ return None