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.
- {methodproof-0.7.6 → methodproof-0.7.8}/.gitignore +2 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/PKG-INFO +1 -1
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/__init__.py +1 -1
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/cli.py +197 -2
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/config.py +58 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/store.py +15 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/pyproject.toml +12 -2
- methodproof-0.7.8/tests/conftest.py +131 -0
- methodproof-0.7.8/tests/test_cli_auth.py +258 -0
- methodproof-0.7.8/tests/test_cli_config.py +123 -0
- methodproof-0.7.8/tests/test_cli_helpers.py +156 -0
- methodproof-0.7.8/tests/test_cli_session.py +153 -0
- methodproof-0.7.8/tests/test_cli_share.py +140 -0
- methodproof-0.7.8/tests/test_cli_start.py +456 -0
- methodproof-0.7.8/tests/test_cli_update.py +121 -0
- methodproof-0.7.8/tests/test_e2e_integration.py +159 -0
- methodproof-0.7.8/tests/test_profiles.py +262 -0
- methodproof-0.7.8/tests/test_sync.py +268 -0
- methodproof-0.7.8/tests/test_viewer.py +151 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/.github/workflows/ci.yml +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/CHANGELOG.md +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/LICENSE +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/README.md +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/__main__.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/_daemon.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/__init__.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/base.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/music.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/terminal.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/agents/watcher.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/analysis.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/binding.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/bip39.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/bridge.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/crypto.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/e2e.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/graph.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hook.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/__init__.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/claude_code.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/claude_code.sh +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/cline_hook.sh +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/codex_hook.sh +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/gemini_hook.sh +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/install.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/kiro_hook.sh +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/mcp_register.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/openclaw/HOOK.md +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/openclaw/handler.ts +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/openclaw_install.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/opencode_plugin.js +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/hooks/wrappers.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/integrity.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/kdf.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/keychain.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/live.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/lock.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/mcp.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/migrate_db.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/proxy.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/proxy_daemon.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/repos.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/skills/methodproof/SKILL.md +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/sync.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/viewer.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/methodproof/wordlist.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/test_windows_compat.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/__init__.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_analysis.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_graph.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_hooks.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_live.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_openclaw_hooks.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_security.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_store.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/tests/test_wrappers.py +0 -0
- {methodproof-0.7.6 → methodproof-0.7.8}/uv.lock +0 -0
|
@@ -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
|
-
|
|
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, "
|
|
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.
|
|
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
|