methodproof 0.7.7__tar.gz → 0.7.9__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.7 → methodproof-0.7.9}/.gitignore +2 -0
  2. {methodproof-0.7.7 → methodproof-0.7.9}/PKG-INFO +1 -1
  3. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/__init__.py +1 -1
  4. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/cli.py +113 -3
  5. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/config.py +58 -0
  6. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/store.py +15 -0
  7. {methodproof-0.7.7 → methodproof-0.7.9}/pyproject.toml +12 -2
  8. methodproof-0.7.9/tests/conftest.py +131 -0
  9. methodproof-0.7.9/tests/test_cli_auth.py +258 -0
  10. methodproof-0.7.9/tests/test_cli_config.py +123 -0
  11. methodproof-0.7.9/tests/test_cli_helpers.py +156 -0
  12. methodproof-0.7.9/tests/test_cli_session.py +153 -0
  13. methodproof-0.7.9/tests/test_cli_share.py +140 -0
  14. methodproof-0.7.9/tests/test_cli_start.py +456 -0
  15. methodproof-0.7.9/tests/test_cli_update.py +121 -0
  16. methodproof-0.7.9/tests/test_e2e_integration.py +159 -0
  17. methodproof-0.7.9/tests/test_profiles.py +262 -0
  18. methodproof-0.7.9/tests/test_sync.py +268 -0
  19. methodproof-0.7.9/tests/test_viewer.py +151 -0
  20. {methodproof-0.7.7 → methodproof-0.7.9}/uv.lock +178 -2
  21. {methodproof-0.7.7 → methodproof-0.7.9}/.github/workflows/ci.yml +0 -0
  22. {methodproof-0.7.7 → methodproof-0.7.9}/CHANGELOG.md +0 -0
  23. {methodproof-0.7.7 → methodproof-0.7.9}/LICENSE +0 -0
  24. {methodproof-0.7.7 → methodproof-0.7.9}/README.md +0 -0
  25. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/__main__.py +0 -0
  26. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/_daemon.py +0 -0
  27. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/agents/__init__.py +0 -0
  28. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/agents/base.py +0 -0
  29. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/agents/music.py +0 -0
  30. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/agents/terminal.py +0 -0
  31. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/agents/watcher.py +0 -0
  32. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/analysis.py +0 -0
  33. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/binding.py +0 -0
  34. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/bip39.py +0 -0
  35. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/bridge.py +0 -0
  36. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/crypto.py +0 -0
  37. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/e2e.py +0 -0
  38. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/graph.py +0 -0
  39. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hook.py +0 -0
  40. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/__init__.py +0 -0
  41. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/claude_code.py +0 -0
  42. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/claude_code.sh +0 -0
  43. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/cline_hook.sh +0 -0
  44. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/codex_hook.sh +0 -0
  45. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/gemini_hook.sh +0 -0
  46. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/install.py +0 -0
  47. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/kiro_hook.sh +0 -0
  48. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/mcp_register.py +0 -0
  49. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/openclaw/HOOK.md +0 -0
  50. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/openclaw/handler.ts +0 -0
  51. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/openclaw_install.py +0 -0
  52. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/opencode_plugin.js +0 -0
  53. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/hooks/wrappers.py +0 -0
  54. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/integrity.py +0 -0
  55. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/kdf.py +0 -0
  56. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/keychain.py +0 -0
  57. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/live.py +0 -0
  58. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/lock.py +0 -0
  59. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/mcp.py +0 -0
  60. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/migrate_db.py +0 -0
  61. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/proxy.py +0 -0
  62. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/proxy_daemon.py +0 -0
  63. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/repos.py +0 -0
  64. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/skills/methodproof/SKILL.md +0 -0
  65. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/sync.py +0 -0
  66. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/viewer.py +0 -0
  67. {methodproof-0.7.7 → methodproof-0.7.9}/methodproof/wordlist.py +0 -0
  68. {methodproof-0.7.7 → methodproof-0.7.9}/test_windows_compat.py +0 -0
  69. {methodproof-0.7.7 → methodproof-0.7.9}/tests/__init__.py +0 -0
  70. {methodproof-0.7.7 → methodproof-0.7.9}/tests/test_analysis.py +0 -0
  71. {methodproof-0.7.7 → methodproof-0.7.9}/tests/test_graph.py +0 -0
  72. {methodproof-0.7.7 → methodproof-0.7.9}/tests/test_hooks.py +0 -0
  73. {methodproof-0.7.7 → methodproof-0.7.9}/tests/test_live.py +0 -0
  74. {methodproof-0.7.7 → methodproof-0.7.9}/tests/test_openclaw_hooks.py +0 -0
  75. {methodproof-0.7.7 → methodproof-0.7.9}/tests/test_security.py +0 -0
  76. {methodproof-0.7.7 → methodproof-0.7.9}/tests/test_store.py +0 -0
  77. {methodproof-0.7.7 → methodproof-0.7.9}/tests/test_wrappers.py +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.7
3
+ Version: 0.7.9
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.7"
3
+ __version__ = "0.7.8"
@@ -404,7 +404,7 @@ def cmd_init(args: argparse.Namespace) -> None:
404
404
  from methodproof.hooks.openclaw_install import install as install_openclaw_hooks, install_skill
405
405
  oc_result = install_openclaw_hooks()
406
406
  if oc_result is None:
407
- print("AI Agent Graph: Claude Code not found (hooks + skill skipped)")
407
+ print("AI Agent Graph: OpenClaw not found (hooks + skill skipped)")
408
408
  else:
409
409
  print(f"AI Agent Graph hooks: {oc_result}")
410
410
  skill_result = install_skill()
@@ -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"
@@ -1327,6 +1338,9 @@ def cmd_status(args: argparse.Namespace) -> None:
1327
1338
  print(f" auth: signed in")
1328
1339
  print(f" account: {account_id[:8]}...{account_id[-4:]}")
1329
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)")
1330
1344
  if claims.get("is_superadmin"):
1331
1345
  print(" superadmin: yes")
1332
1346
 
@@ -1391,6 +1405,92 @@ def cmd_logout(args: argparse.Namespace) -> None:
1391
1405
  print(f"Logged out ({label}). Run `mp login` to sign in again.")
1392
1406
 
1393
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
+
1394
1494
  def cmd_login(args: argparse.Namespace) -> None:
1395
1495
  import webbrowser
1396
1496
  from methodproof.sync import _request
@@ -1404,6 +1504,8 @@ def cmd_login(args: argparse.Namespace) -> None:
1404
1504
  answer = input(" Switch accounts? [y/N]: ").strip().lower()
1405
1505
  if answer not in ("y", "yes"):
1406
1506
  return
1507
+ # Stash current profile before switching
1508
+ config.save_active_profile(cfg)
1407
1509
 
1408
1510
  # Start device auth flow
1409
1511
  result = _request("POST", "/auth/cli/start", api, "")
@@ -1437,12 +1539,17 @@ def cmd_login(args: argparse.Namespace) -> None:
1437
1539
  cfg["last_auth_at"] = time.time()
1438
1540
  cfg["master_key_fingerprint"] = "" # clear stale fingerprint from previous account
1439
1541
  config.save(cfg)
1542
+ config.save_active_profile(cfg)
1440
1543
  print(" done.\n")
1441
1544
  if not getattr(args, "no_key", False):
1442
1545
  _setup_master_key(cfg)
1443
1546
  from methodproof.sync import sync_research_consent
1444
1547
  sync_research_consent(cfg["token"], cfg["api_url"])
1445
- 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`")
1446
1553
  return
1447
1554
  except Exception:
1448
1555
  pass
@@ -1868,6 +1975,9 @@ def main() -> None:
1868
1975
  l.add_argument("--force", "-f", action="store_true", help="Skip switch-account prompt")
1869
1976
  l.add_argument("--no-key", action="store_true", help="Skip master key generation (test accounts)")
1870
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")
1871
1981
  pu = sub.add_parser("push", help="Upload privately to your account")
1872
1982
  pu.add_argument("session_id", nargs="?")
1873
1983
  pu.add_argument("--local", action="store_true", help="Push to local dev API (localhost:8000)")
@@ -1928,7 +2038,7 @@ def main() -> None:
1928
2038
  cmds = {
1929
2039
  "init": cmd_init, "start": cmd_start, "stop": cmd_stop,
1930
2040
  "view": cmd_view, "log": cmd_log, "status": cmd_status,
1931
- "login": cmd_login, "logout": cmd_logout,
2041
+ "login": cmd_login, "logout": cmd_logout, "accounts": cmd_accounts, "switch": cmd_switch,
1932
2042
  "push": cmd_push, "tag": cmd_tag, "publish": cmd_publish,
1933
2043
  "delete": cmd_delete, "review": cmd_review, "consent": cmd_consent,
1934
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.7"
3
+ version = "0.7.9"
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