methodproof 0.7.6__tar.gz → 0.7.8__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 (77) hide show
  1. {methodproof-0.7.6 → methodproof-0.7.8}/.gitignore +2 -0
  2. {methodproof-0.7.6 → methodproof-0.7.8}/PKG-INFO +1 -1
  3. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/__init__.py +1 -1
  4. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/cli.py +197 -2
  5. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/config.py +58 -0
  6. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/store.py +15 -0
  7. {methodproof-0.7.6 → methodproof-0.7.8}/pyproject.toml +12 -2
  8. methodproof-0.7.8/tests/conftest.py +131 -0
  9. methodproof-0.7.8/tests/test_cli_auth.py +258 -0
  10. methodproof-0.7.8/tests/test_cli_config.py +123 -0
  11. methodproof-0.7.8/tests/test_cli_helpers.py +156 -0
  12. methodproof-0.7.8/tests/test_cli_session.py +153 -0
  13. methodproof-0.7.8/tests/test_cli_share.py +140 -0
  14. methodproof-0.7.8/tests/test_cli_start.py +456 -0
  15. methodproof-0.7.8/tests/test_cli_update.py +121 -0
  16. methodproof-0.7.8/tests/test_e2e_integration.py +159 -0
  17. methodproof-0.7.8/tests/test_profiles.py +262 -0
  18. methodproof-0.7.8/tests/test_sync.py +268 -0
  19. methodproof-0.7.8/tests/test_viewer.py +151 -0
  20. {methodproof-0.7.6 → methodproof-0.7.8}/.github/workflows/ci.yml +0 -0
  21. {methodproof-0.7.6 → methodproof-0.7.8}/CHANGELOG.md +0 -0
  22. {methodproof-0.7.6 → methodproof-0.7.8}/LICENSE +0 -0
  23. {methodproof-0.7.6 → methodproof-0.7.8}/README.md +0 -0
  24. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/__main__.py +0 -0
  25. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/_daemon.py +0 -0
  26. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/__init__.py +0 -0
  27. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/base.py +0 -0
  28. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/music.py +0 -0
  29. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/terminal.py +0 -0
  30. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/watcher.py +0 -0
  31. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/analysis.py +0 -0
  32. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/binding.py +0 -0
  33. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/bip39.py +0 -0
  34. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/bridge.py +0 -0
  35. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/crypto.py +0 -0
  36. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/e2e.py +0 -0
  37. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/graph.py +0 -0
  38. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hook.py +0 -0
  39. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/__init__.py +0 -0
  40. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/claude_code.py +0 -0
  41. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/claude_code.sh +0 -0
  42. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/cline_hook.sh +0 -0
  43. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/codex_hook.sh +0 -0
  44. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/gemini_hook.sh +0 -0
  45. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/install.py +0 -0
  46. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/kiro_hook.sh +0 -0
  47. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/mcp_register.py +0 -0
  48. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/openclaw/HOOK.md +0 -0
  49. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/openclaw/handler.ts +0 -0
  50. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/openclaw_install.py +0 -0
  51. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/opencode_plugin.js +0 -0
  52. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/wrappers.py +0 -0
  53. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/integrity.py +0 -0
  54. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/kdf.py +0 -0
  55. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/keychain.py +0 -0
  56. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/live.py +0 -0
  57. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/lock.py +0 -0
  58. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/mcp.py +0 -0
  59. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/migrate_db.py +0 -0
  60. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/proxy.py +0 -0
  61. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/proxy_daemon.py +0 -0
  62. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/repos.py +0 -0
  63. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/skills/methodproof/SKILL.md +0 -0
  64. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/sync.py +0 -0
  65. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/viewer.py +0 -0
  66. {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/wordlist.py +0 -0
  67. {methodproof-0.7.6 → methodproof-0.7.8}/test_windows_compat.py +0 -0
  68. {methodproof-0.7.6 → methodproof-0.7.8}/tests/__init__.py +0 -0
  69. {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_analysis.py +0 -0
  70. {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_graph.py +0 -0
  71. {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_hooks.py +0 -0
  72. {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_live.py +0 -0
  73. {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_openclaw_hooks.py +0 -0
  74. {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_security.py +0 -0
  75. {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_store.py +0 -0
  76. {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_wrappers.py +0 -0
  77. {methodproof-0.7.6 → methodproof-0.7.8}/uv.lock +0 -0
@@ -8,3 +8,5 @@ build/
8
8
  *.key
9
9
  *.pem
10
10
  .DS_Store
11
+ .coverage
12
+ htmlcov/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.7.6
3
+ Version: 0.7.8
4
4
  Summary: See how you code. Capture and visualize your engineering process.
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,3 +1,3 @@
1
1
  """MethodProof — see how you code."""
2
2
 
3
- __version__ = "0.7.6"
3
+ __version__ = "0.7.8"
@@ -502,6 +502,8 @@ def _print_commands() -> None:
502
502
  print()
503
503
  print(f" {_W}ACCOUNT{R}")
504
504
  print(f" {_M}mp login{R} Connect to platform (opens browser)")
505
+ print(f" {_M}mp accounts{R} List all accounts on this device")
506
+ print(f" {_M}mp switch{R} {_D}[query]{R} Quick-swap to another account")
505
507
  print(f" {_M}mp consent{R} Change capture, research, and redaction settings")
506
508
  print(f" {_M}mp lock{R} Destroy local encryption key {_D}(reversible){R}")
507
509
  print(f" {_M}mp lock --purge{R} Delete all local data {_D}(irreversible){R}")
@@ -561,6 +563,8 @@ def _print_commands_plain() -> None:
561
563
  print()
562
564
  print(" ACCOUNT")
563
565
  print(" mp login Connect to platform (opens browser)")
566
+ print(" mp accounts List all accounts on this device")
567
+ print(" mp switch [query] Quick-swap to another account")
564
568
  print(" mp consent Change capture, research, and redaction settings")
565
569
  print(" mp lock Destroy local encryption key (reversible)")
566
570
  print(" mp lock --purge Delete all local data (irreversible)")
@@ -982,6 +986,13 @@ def cmd_start(args: argparse.Namespace) -> None:
982
986
  _log_step("Creating session")
983
987
  sid = uuid.uuid4().hex
984
988
  watch_dir = os.path.abspath(args.dir or ".")
989
+
990
+ # Prevent concurrent sessions watching overlapping directories
991
+ conflict = store.find_active_for_dir(watch_dir)
992
+ if conflict:
993
+ print(f"Active session {conflict['id'][:8]} already watches {conflict['watch_dir']}")
994
+ print("Run `methodproof stop` first, or choose a different directory.")
995
+ sys.exit(1)
985
996
  repo_url = args.repo or repos.detect_repo(watch_dir)
986
997
  tags = args.tags.split(",") if args.tags else []
987
998
  visibility = "public" if args.public else "private"
@@ -1293,6 +1304,92 @@ def cmd_log(args: argparse.Namespace) -> None:
1293
1304
  print(f" {s['id'][:8]} {dt} {dur} {s['total_events']} events{suffix}")
1294
1305
 
1295
1306
 
1307
+ def cmd_status(args: argparse.Namespace) -> None:
1308
+ """Show auth, session, and config status at a glance."""
1309
+ from methodproof import __version__
1310
+ cfg = config.load()
1311
+ token = cfg.get("token", "")
1312
+ claims = _decode_jwt_claims(token) if token else {}
1313
+ sessions = store.list_sessions()
1314
+ active = cfg.get("active_session")
1315
+ capture = cfg.get("capture", {})
1316
+ enabled = [k for k, v in capture.items() if v]
1317
+
1318
+ print(f"\n methodproof v{__version__}")
1319
+ print(f" api: {cfg.get('api_url', '—')}\n")
1320
+
1321
+ # Auth
1322
+ if not token:
1323
+ print(" auth: not signed in")
1324
+ else:
1325
+ account_id = claims.get("user_id", "—")
1326
+ role = claims.get("role", "—")
1327
+ acct_type = claims.get("account_type", "—")
1328
+ exp = claims.get("exp", 0)
1329
+ if exp and time.time() > exp:
1330
+ expiry = "expired"
1331
+ elif exp:
1332
+ remaining = int(exp - time.time())
1333
+ hours = remaining // 3600
1334
+ mins = (remaining % 3600) // 60
1335
+ expiry = f"{hours}h {mins}m remaining"
1336
+ else:
1337
+ expiry = "unknown"
1338
+ print(f" auth: signed in")
1339
+ print(f" account: {account_id[:8]}...{account_id[-4:]}")
1340
+ print(f" role: {role} | type: {acct_type} | token: {expiry}")
1341
+ n_profiles = len(cfg.get("profiles", {}))
1342
+ if n_profiles > 1:
1343
+ print(f" accounts: {n_profiles} on this device (`mp switch` to swap)")
1344
+ if claims.get("is_superadmin"):
1345
+ print(" superadmin: yes")
1346
+
1347
+ # Session
1348
+ print()
1349
+ if active:
1350
+ sess = store.get_session(active)
1351
+ if sess:
1352
+ dt = datetime.fromtimestamp(sess["created_at"], tz=UTC).strftime("%H:%M")
1353
+ print(f" session: RECORDING {active[:8]} started {dt} ({sess['total_events']} events)")
1354
+ else:
1355
+ print(f" session: RECORDING {active[:8]}")
1356
+ else:
1357
+ print(" session: idle")
1358
+
1359
+ # Local sessions
1360
+ total = len(sessions)
1361
+ unsynced = len([s for s in sessions if not s["synced"] and s.get("completed_at") and s["total_events"] > 0])
1362
+ print(f" local: {total} session{'s' if total != 1 else ''}", end="")
1363
+ if unsynced:
1364
+ print(f" ({unsynced} unsynced)")
1365
+ else:
1366
+ print()
1367
+
1368
+ # Capture config
1369
+ print(f"\n consent: {len(enabled)}/11 categories")
1370
+ full_spectrum = len(enabled) >= 10
1371
+ if full_spectrum:
1372
+ print(" spectrum: FULL")
1373
+
1374
+ # Modes
1375
+ modes = []
1376
+ if cfg.get("journal_mode"):
1377
+ credits = cfg.get("journal_credits", 0)
1378
+ modes.append(f"journal ({credits} credits)")
1379
+ if cfg.get("e2e_mode"):
1380
+ fp = cfg.get("e2e_fingerprint", "")
1381
+ modes.append(f"e2e ({fp[:8]})" if fp else "e2e")
1382
+ if modes:
1383
+ print(f" modes: {' | '.join(modes)}")
1384
+
1385
+ # Research
1386
+ if cfg.get("research_consent"):
1387
+ level = cfg.get("contribution_level", "structural")
1388
+ print(f" research: opted in ({level})")
1389
+
1390
+ print()
1391
+
1392
+
1296
1393
  def cmd_logout(args: argparse.Namespace) -> None:
1297
1394
  """Clear login credentials only. Keeps consent, sessions, and hooks."""
1298
1395
  cfg = config.load()
@@ -1308,6 +1405,92 @@ def cmd_logout(args: argparse.Namespace) -> None:
1308
1405
  print(f"Logged out ({label}). Run `mp login` to sign in again.")
1309
1406
 
1310
1407
 
1408
+ def cmd_accounts(args: argparse.Namespace) -> None:
1409
+ """List all accounts stored on this device."""
1410
+ cfg = config.load()
1411
+ profiles = config.list_profiles(cfg)
1412
+ if not profiles:
1413
+ print("No accounts. Run `mp login` to sign in.")
1414
+ return
1415
+ print()
1416
+ for p in profiles:
1417
+ marker = "*" if p.get("active") else " "
1418
+ email = p.get("email", "")
1419
+ aid = p.get("account_id", "")[:8]
1420
+ label = email or aid or "unknown"
1421
+ # Token status
1422
+ token = p.get("token", "")
1423
+ if token:
1424
+ claims = _decode_jwt_claims(token)
1425
+ exp = claims.get("exp", 0)
1426
+ if exp and time.time() > exp:
1427
+ status = "expired"
1428
+ elif exp:
1429
+ remaining = int(exp - time.time())
1430
+ h, m = remaining // 3600, (remaining % 3600) // 60
1431
+ status = f"{h}h {m}m"
1432
+ else:
1433
+ status = "valid"
1434
+ else:
1435
+ status = "no token"
1436
+ print(f" {marker} {label} ({aid}) token: {status}")
1437
+ print(f"\n Switch: `mp switch <email or id prefix>`\n")
1438
+
1439
+
1440
+ def cmd_switch(args: argparse.Namespace) -> None:
1441
+ """Quick-swap to a stored account profile."""
1442
+ cfg = config.load()
1443
+ profiles = cfg.get("profiles", {})
1444
+
1445
+ if not profiles:
1446
+ print("No stored accounts. Run `mp login` to add one.")
1447
+ return
1448
+
1449
+ query = getattr(args, "account", None)
1450
+
1451
+ if query:
1452
+ # Direct match
1453
+ target = config.find_profile(cfg, query)
1454
+ if not target:
1455
+ print(f"No account matching '{query}'. Run `mp accounts` to see stored accounts.")
1456
+ return
1457
+ else:
1458
+ # Interactive picker
1459
+ items = [(aid, p) for aid, p in profiles.items() if aid != cfg.get("account_id")]
1460
+ if not items:
1461
+ print("Only one account stored. Run `mp login` to add another.")
1462
+ return
1463
+ print()
1464
+ for i, (aid, p) in enumerate(items, 1):
1465
+ label = p.get("email") or aid[:8]
1466
+ print(f" {i}. {label} ({aid[:8]})")
1467
+ print()
1468
+ try:
1469
+ choice = input(f" Switch to [1-{len(items)}]: ").strip()
1470
+ idx = int(choice) - 1
1471
+ if not 0 <= idx < len(items):
1472
+ print("Cancelled.")
1473
+ return
1474
+ except (ValueError, EOFError, KeyboardInterrupt):
1475
+ print("\nCancelled.")
1476
+ return
1477
+ target = items[idx][0]
1478
+
1479
+ if target == cfg.get("account_id"):
1480
+ label = cfg.get("email") or target[:8]
1481
+ print(f"Already active: {label}")
1482
+ return
1483
+
1484
+ if config.restore_profile(cfg, target):
1485
+ cfg = config.load() # reload after swap
1486
+ label = cfg.get("email") or cfg.get("account_id", "")[:8]
1487
+ print(f"Switched to {label} ({cfg.get('account_id', '')[:8]}).")
1488
+ # Re-setup master key for this account
1489
+ _setup_master_key(cfg)
1490
+ else:
1491
+ print(f"Profile not found for {target[:8]}. Run `mp login`.")
1492
+
1493
+
1311
1494
  def cmd_login(args: argparse.Namespace) -> None:
1312
1495
  import webbrowser
1313
1496
  from methodproof.sync import _request
@@ -1321,6 +1504,8 @@ def cmd_login(args: argparse.Namespace) -> None:
1321
1504
  answer = input(" Switch accounts? [y/N]: ").strip().lower()
1322
1505
  if answer not in ("y", "yes"):
1323
1506
  return
1507
+ # Stash current profile before switching
1508
+ config.save_active_profile(cfg)
1324
1509
 
1325
1510
  # Start device auth flow
1326
1511
  result = _request("POST", "/auth/cli/start", api, "")
@@ -1354,12 +1539,17 @@ def cmd_login(args: argparse.Namespace) -> None:
1354
1539
  cfg["last_auth_at"] = time.time()
1355
1540
  cfg["master_key_fingerprint"] = "" # clear stale fingerprint from previous account
1356
1541
  config.save(cfg)
1542
+ config.save_active_profile(cfg)
1357
1543
  print(" done.\n")
1358
1544
  if not getattr(args, "no_key", False):
1359
1545
  _setup_master_key(cfg)
1360
1546
  from methodproof.sync import sync_research_consent
1361
1547
  sync_research_consent(cfg["token"], cfg["api_url"])
1362
- print("Logged in. Run `methodproof push` to upload sessions.")
1548
+ label = cfg.get("email") or cfg.get("account_id", "")[:8]
1549
+ profiles = cfg.get("profiles", {})
1550
+ n = len(profiles)
1551
+ print(f"Logged in as {label}. {n} account{'s' if n != 1 else ''} on this device.")
1552
+ print(" Quick-swap: `mp switch`")
1363
1553
  return
1364
1554
  except Exception:
1365
1555
  pass
@@ -1779,11 +1969,15 @@ def main() -> None:
1779
1969
  v = sub.add_parser("view", help="Inspect captured session data")
1780
1970
  v.add_argument("session_id", nargs="?")
1781
1971
  sub.add_parser("log", help="List sessions")
1972
+ sub.add_parser("status", help="Auth, session, and config status")
1782
1973
  l = sub.add_parser("login", help="Connect to platform")
1783
1974
  l.add_argument("--api-url")
1784
1975
  l.add_argument("--force", "-f", action="store_true", help="Skip switch-account prompt")
1785
1976
  l.add_argument("--no-key", action="store_true", help="Skip master key generation (test accounts)")
1786
1977
  sub.add_parser("logout", help="Clear login credentials (keeps consent and sessions)")
1978
+ sub.add_parser("accounts", help="List all accounts on this device")
1979
+ sw = sub.add_parser("switch", help="Quick-swap to another account")
1980
+ sw.add_argument("account", nargs="?", help="Email or account ID prefix")
1787
1981
  pu = sub.add_parser("push", help="Upload privately to your account")
1788
1982
  pu.add_argument("session_id", nargs="?")
1789
1983
  pu.add_argument("--local", action="store_true", help="Push to local dev API (localhost:8000)")
@@ -1843,7 +2037,8 @@ def main() -> None:
1843
2037
  args = p.parse_args()
1844
2038
  cmds = {
1845
2039
  "init": cmd_init, "start": cmd_start, "stop": cmd_stop,
1846
- "view": cmd_view, "log": cmd_log, "login": cmd_login, "logout": cmd_logout,
2040
+ "view": cmd_view, "log": cmd_log, "status": cmd_status,
2041
+ "login": cmd_login, "logout": cmd_logout, "accounts": cmd_accounts, "switch": cmd_switch,
1847
2042
  "push": cmd_push, "tag": cmd_tag, "publish": cmd_publish,
1848
2043
  "delete": cmd_delete, "review": cmd_review, "consent": cmd_consent,
1849
2044
  "update": cmd_update, "lock": cmd_lock, "reset": cmd_reset, "uninstall": cmd_uninstall,
@@ -50,6 +50,7 @@ _DEFAULTS: dict[str, Any] = {
50
50
  "ai_responses": True,
51
51
  "code_capture": True,
52
52
  },
53
+ "profiles": {},
53
54
  }
54
55
 
55
56
  FREE_JOURNAL_MAX_HOURS = 4
@@ -154,3 +155,60 @@ def save(cfg: dict[str, Any]) -> None:
154
155
  ensure_dirs()
155
156
  CONFIG.write_text(json.dumps(cfg, indent=2) + "\n")
156
157
  secure_file(CONFIG)
158
+
159
+
160
+ # --- Multi-account profiles ---
161
+
162
+ _PROFILE_KEYS = [
163
+ "token", "refresh_token", "email", "account_id",
164
+ "last_auth_at", "master_key_fingerprint",
165
+ "e2e_mode", "e2e_fingerprint",
166
+ "journal_mode", "journal_credits",
167
+ ]
168
+
169
+
170
+ def save_active_profile(cfg: dict[str, Any]) -> None:
171
+ """Stash current auth state into profiles dict, keyed by account_id."""
172
+ aid = cfg.get("account_id")
173
+ if not aid:
174
+ return
175
+ profiles = cfg.setdefault("profiles", {})
176
+ profiles[aid] = {k: cfg.get(k, _DEFAULTS.get(k, "")) for k in _PROFILE_KEYS}
177
+ save(cfg)
178
+
179
+
180
+ def restore_profile(cfg: dict[str, Any], account_id: str) -> bool:
181
+ """Swap active auth state to a stored profile. Returns False if not found."""
182
+ profiles = cfg.get("profiles", {})
183
+ profile = profiles.get(account_id)
184
+ if not profile:
185
+ return False
186
+ save_active_profile(cfg)
187
+ for k in _PROFILE_KEYS:
188
+ cfg[k] = profile.get(k, _DEFAULTS.get(k, ""))
189
+ save(cfg)
190
+ return True
191
+
192
+
193
+ def list_profiles(cfg: dict[str, Any]) -> list[dict[str, Any]]:
194
+ """Return stored profiles with 'active' flag on the current account."""
195
+ active_id = cfg.get("account_id", "")
196
+ profiles = cfg.get("profiles", {})
197
+ result = []
198
+ for aid, p in profiles.items():
199
+ result.append({**p, "active": aid == active_id})
200
+ if active_id and active_id not in profiles:
201
+ result.append({
202
+ k: cfg.get(k, _DEFAULTS.get(k, "")) for k in _PROFILE_KEYS
203
+ } | {"active": True})
204
+ return result
205
+
206
+
207
+ def find_profile(cfg: dict[str, Any], query: str) -> str | None:
208
+ """Match a profile by email or account_id prefix. Returns account_id or None."""
209
+ profiles = cfg.get("profiles", {})
210
+ q = query.lower().strip()
211
+ for aid, p in profiles.items():
212
+ if p.get("email", "").lower() == q or aid.startswith(q):
213
+ return aid
214
+ return None
@@ -1,6 +1,7 @@
1
1
  """SQLite store — sessions, events, graph relationships."""
2
2
 
3
3
  import json
4
+ import os
4
5
  import sqlite3
5
6
  import time
6
7
  import uuid
@@ -165,6 +166,20 @@ def create_session(
165
166
  _db().commit()
166
167
 
167
168
 
169
+ def find_active_for_dir(watch_dir: str) -> dict[str, str] | None:
170
+ """Find an active session watching the same or overlapping directory."""
171
+ rows = _db().execute(
172
+ "SELECT id, watch_dir FROM sessions WHERE completed_at IS NULL",
173
+ ).fetchall()
174
+ for r in rows:
175
+ existing = r[1]
176
+ if (watch_dir == existing
177
+ or watch_dir.startswith(existing + os.sep)
178
+ or existing.startswith(watch_dir + os.sep)):
179
+ return {"id": r[0], "watch_dir": r[1]}
180
+ return None
181
+
182
+
168
183
  def complete_session(session_id: str) -> None:
169
184
  db = _db()
170
185
  count = db.execute(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "methodproof"
3
- version = "0.7.6"
3
+ version = "0.7.8"
4
4
  description = "See how you code. Capture and visualize your engineering process."
5
5
  requires-python = ">=3.11"
6
6
  dependencies = ["watchdog>=4.0", "websocket-client>=1.7", "cryptography>=43.0", "keyring>=25.0"]
@@ -11,7 +11,7 @@ readme = "README.md"
11
11
  proxy = ["mitmproxy>=10.0"]
12
12
 
13
13
  [dependency-groups]
14
- dev = ["pytest>=8.0"]
14
+ dev = ["pytest>=8.0", "pytest-cov>=5.0"]
15
15
 
16
16
  [project.scripts]
17
17
  methodproof = "methodproof.cli:main"
@@ -25,3 +25,13 @@ packages = ["methodproof"]
25
25
 
26
26
  [tool.pytest.ini_options]
27
27
  testpaths = ["tests"]
28
+ markers = [
29
+ "e2e: requires Docker infrastructure (just infra-up && just seed && just platform)",
30
+ ]
31
+
32
+ [tool.coverage.run]
33
+ source = ["methodproof"]
34
+ omit = ["methodproof/wordlist.py"]
35
+
36
+ [tool.coverage.report]
37
+ show_missing = true
@@ -0,0 +1,131 @@
1
+ """Shared fixtures for CLI tests."""
2
+
3
+ import argparse
4
+ import base64
5
+ import json
6
+ import time
7
+ import uuid
8
+ import zlib
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import pytest
13
+
14
+ from methodproof import config, store
15
+
16
+
17
+ # ── Filesystem isolation ──
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def isolate_fs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
22
+ """Isolate all config/DB/log paths to tmp_path. Writes defaults to disk
23
+ so config.load() always parses JSON (avoids _DEFAULTS shallow-copy mutation)."""
24
+ mp_dir = tmp_path / ".methodproof"
25
+ mp_dir.mkdir()
26
+ monkeypatch.setattr(config, "DIR", mp_dir)
27
+ monkeypatch.setattr(config, "CONFIG", mp_dir / "config.json")
28
+ monkeypatch.setattr(config, "DB_PATH", mp_dir / "methodproof.db")
29
+ monkeypatch.setattr(config, "CMD_LOG", mp_dir / "commands.jsonl")
30
+ (mp_dir / "config.json").write_text(json.dumps(dict(config._DEFAULTS), indent=2))
31
+ monkeypatch.setattr(store, "_conn", None)
32
+ store.init_db()
33
+ return tmp_path
34
+
35
+
36
+ # ── JWT helper ──
37
+
38
+
39
+ @pytest.fixture
40
+ def fake_jwt():
41
+ """Factory: builds a real base64-encoded JWT so _decode_jwt_claims works."""
42
+ def _make(
43
+ user_id: str = "test-account-123",
44
+ role: str = "admin",
45
+ account_type: str = "pro",
46
+ exp: float | None = None,
47
+ **extra: Any,
48
+ ) -> str:
49
+ header = base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode()
50
+ claims = {
51
+ "user_id": user_id,
52
+ "role": role,
53
+ "account_type": account_type,
54
+ "exp": exp if exp is not None else time.time() + 3600,
55
+ **extra,
56
+ }
57
+ payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode()
58
+ sig = base64.urlsafe_b64encode(b"fakesig").rstrip(b"=").decode()
59
+ return f"{header}.{payload}.{sig}"
60
+ return _make
61
+
62
+
63
+ # ── Config helpers ──
64
+
65
+
66
+ @pytest.fixture
67
+ def logged_in_cfg(fake_jwt):
68
+ """Factory: writes a config with valid auth state, returns cfg dict."""
69
+ def _make(
70
+ account_id: str = "test-account-123",
71
+ email: str = "test@methodproof.com",
72
+ token: str | None = None,
73
+ **extra: Any,
74
+ ) -> dict[str, Any]:
75
+ cfg = config.load()
76
+ cfg["token"] = token or fake_jwt(user_id=account_id)
77
+ cfg["account_id"] = account_id
78
+ cfg["email"] = email
79
+ cfg["last_auth_at"] = time.time()
80
+ cfg.update(extra)
81
+ config.save(cfg)
82
+ return cfg
83
+ return _make
84
+
85
+
86
+ # ── Session factory ──
87
+
88
+
89
+ @pytest.fixture
90
+ def make_session():
91
+ """Factory: creates a completed session with N events in SQLite."""
92
+ def _make(n_events: int = 5, account_id: str = "test-account-123",
93
+ watch_dir: str = "/tmp/test", **session_kwargs: Any) -> tuple[str, list[dict]]:
94
+ sid = uuid.uuid4().hex
95
+ store.create_session(sid, watch_dir, account_id=account_id, **session_kwargs)
96
+ events = []
97
+ base_ts = time.time() - 300
98
+ for i in range(n_events):
99
+ e = {
100
+ "id": uuid.uuid4().hex,
101
+ "type": ["file_edit", "terminal_cmd", "llm_prompt", "git_commit", "test_run"][i % 5],
102
+ "timestamp": base_ts + i * 10,
103
+ "duration_ms": 100 + i * 50,
104
+ "metadata": {"path": f"file_{i}.py", "language": "python"},
105
+ }
106
+ events.append(e)
107
+ store.insert_events(sid, events)
108
+ store.complete_session(sid)
109
+ return sid, events
110
+ return _make
111
+
112
+
113
+ # ── Argparse helper ──
114
+
115
+
116
+ @pytest.fixture
117
+ def cli_args():
118
+ """Factory: builds argparse.Namespace with sensible defaults."""
119
+ def _make(**kwargs: Any) -> argparse.Namespace:
120
+ defaults = {
121
+ "session_id": None, "dir": None, "repo": None, "tags": None,
122
+ "public": False, "live": False, "live_public": False,
123
+ "journal": False, "e2e": False, "no_e2e": False,
124
+ "verbose": False, "streaming": False, "force": False,
125
+ "local": False, "api_url": None, "no_key": False, "auto": None,
126
+ "account": None, "purge": False, "keep_sessions": False,
127
+ "anonymous": False,
128
+ }
129
+ defaults.update(kwargs)
130
+ return argparse.Namespace(**defaults)
131
+ return _make