reasonwork 0.6.0__tar.gz → 0.6.2__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.
- {reasonwork-0.6.0/src/reasonwork.egg-info → reasonwork-0.6.2}/PKG-INFO +1 -1
- {reasonwork-0.6.0 → reasonwork-0.6.2/src/reasonwork.egg-info}/PKG-INFO +1 -1
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/__init__.py +1 -1
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/analyzer.py +182 -14
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/__init__.py +14 -1
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/config.py +34 -5
- reasonwork-0.6.2/src/reasonwork_exchange/cli/login.py +75 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/scan.py +1 -1
- reasonwork-0.6.0/src/reasonwork_exchange/cli/login.py +0 -39
- {reasonwork-0.6.0 → reasonwork-0.6.2}/LICENSE +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/MANIFEST.in +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/PYPI_README.md +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/pyproject.toml +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/setup.cfg +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/SOURCES.txt +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/dependency_links.txt +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/entry_points.txt +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/requires.txt +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/top_level.txt +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/__main__.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/auth.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/hook.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/schedule.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/sync.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/update.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/upload.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/client.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/config.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/discovery.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/errors.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/filesystem.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/output.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/state.py +0 -0
- {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/version_check.py +0 -0
|
@@ -6,6 +6,7 @@ import hashlib
|
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
8
|
from collections import Counter
|
|
9
|
+
from collections.abc import Callable
|
|
9
10
|
from dataclasses import asdict, dataclass, field
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any
|
|
@@ -35,6 +36,48 @@ def _luhn_valid(candidate: str) -> bool:
|
|
|
35
36
|
total += digit
|
|
36
37
|
return total % 10 == 0
|
|
37
38
|
|
|
39
|
+
|
|
40
|
+
# Dihedral-group tables for the Verhoeff checksum Aadhaar numbers carry.
|
|
41
|
+
_VERHOEFF_D = (
|
|
42
|
+
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
43
|
+
(1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
|
|
44
|
+
(2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
|
|
45
|
+
(3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
|
|
46
|
+
(4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
|
|
47
|
+
(5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
|
|
48
|
+
(6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
|
|
49
|
+
(7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
|
|
50
|
+
(8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
|
|
51
|
+
(9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
|
|
52
|
+
)
|
|
53
|
+
_VERHOEFF_P = (
|
|
54
|
+
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
55
|
+
(1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
|
|
56
|
+
(5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
|
|
57
|
+
(8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
|
|
58
|
+
(9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
|
|
59
|
+
(4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
|
|
60
|
+
(2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
|
|
61
|
+
(7, 0, 4, 6, 9, 1, 3, 2, 5, 8),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _aadhaar_valid(candidate: str) -> bool:
|
|
66
|
+
"""Verhoeff-validate a digit run shaped like an Aadhaar number.
|
|
67
|
+
|
|
68
|
+
Any 12-digit run (timestamps, ids, byte counts) matches the pattern, so
|
|
69
|
+
without the checksum the contiguous form would flood with false
|
|
70
|
+
positives -- the same reason credit_card goes through _luhn_valid.
|
|
71
|
+
"""
|
|
72
|
+
digits = re.sub(r"\D", "", candidate)
|
|
73
|
+
if len(digits) != 12:
|
|
74
|
+
return False
|
|
75
|
+
checksum = 0
|
|
76
|
+
for index, char in enumerate(reversed(digits)):
|
|
77
|
+
checksum = _VERHOEFF_D[checksum][_VERHOEFF_P[index % 8][int(char)]]
|
|
78
|
+
return checksum == 0
|
|
79
|
+
|
|
80
|
+
|
|
38
81
|
_SECRET_PATTERNS = {
|
|
39
82
|
# Matches the whole PEM block through its matching END marker so the base64 body
|
|
40
83
|
# isn't left in plaintext after redaction; falls back to the header alone if no
|
|
@@ -53,6 +96,7 @@ _SECRET_PATTERNS = {
|
|
|
53
96
|
"stripe_key": re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
|
|
54
97
|
"google_api_key": re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
|
|
55
98
|
"npm_token": re.compile(r"\bnpm_[A-Za-z0-9]{36}\b"),
|
|
99
|
+
"reasonwork_contributor_token": re.compile(r"\bnxc_[A-Za-z0-9_-]{20,}\b"),
|
|
56
100
|
"db_connection_password": re.compile(
|
|
57
101
|
r"\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^\s\"'/:@]+:[^\s\"'/@]+@[^\s\"']+",
|
|
58
102
|
re.IGNORECASE,
|
|
@@ -172,7 +216,7 @@ _SECRET_PATTERNS = {
|
|
|
172
216
|
"iban": re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b"),
|
|
173
217
|
"uk_national_insurance_number": re.compile(r"\b[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}\d{6}[A-D]\b"),
|
|
174
218
|
"india_pan": re.compile(r"\b[A-Z]{5}\d{4}[A-Z]\b"),
|
|
175
|
-
"india_aadhaar": re.compile(r"\b\d{
|
|
219
|
+
"india_aadhaar": re.compile(r"\b[2-9]\d{3}[\s-]?\d{4}[\s-]?\d{4}\b"),
|
|
176
220
|
"us_itin": re.compile(r"\b9\d{2}-\d{2}-\d{4}\b"),
|
|
177
221
|
"passport_mrz_line": re.compile(r"\bP<[A-Z<]{43}\b"),
|
|
178
222
|
"drivers_license_assignment": re.compile(
|
|
@@ -294,6 +338,14 @@ _SECRET_PATTERNS = {
|
|
|
294
338
|
"diners_club_card": re.compile(r"\b3(?:0[0-5]|[68][0-9])[0-9]{11}\b"),
|
|
295
339
|
"jcb_card": re.compile(r"\b(?:2131|1800|35\d{2})\d{12}\b"),
|
|
296
340
|
}
|
|
341
|
+
# Checksum validators applied to a pattern's raw matches before they count as
|
|
342
|
+
# findings (and before redaction). Keeps loose numeric patterns usable.
|
|
343
|
+
_SECRET_VALIDATORS = {
|
|
344
|
+
"credit_card": _luhn_valid,
|
|
345
|
+
"diners_club_card": _luhn_valid,
|
|
346
|
+
"jcb_card": _luhn_valid,
|
|
347
|
+
"india_aadhaar": _aadhaar_valid,
|
|
348
|
+
}
|
|
297
349
|
_EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
|
|
298
350
|
_PHONE_PATTERN = re.compile(
|
|
299
351
|
r"\b(?:\+?1[-.\s])?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b"
|
|
@@ -410,6 +462,10 @@ def _codex_agent_id(record: dict[str, Any]) -> str | None:
|
|
|
410
462
|
|
|
411
463
|
|
|
412
464
|
_EDIT_TOOL_NAMES = {"Edit", "Write", "NotebookEdit", "MultiEdit", "apply_patch"}
|
|
465
|
+
# Current Codex routes nearly every action through one custom tool, `exec`,
|
|
466
|
+
# whose `input` is a script calling `tools.<name>(...)`; the nested names are
|
|
467
|
+
# the real tool types.
|
|
468
|
+
_CODEX_NESTED_TOOL = re.compile(r"\btools\.([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
413
469
|
|
|
414
470
|
|
|
415
471
|
@dataclass
|
|
@@ -500,6 +556,12 @@ def analyze(path: Path, discovered_source: str | None = None) -> Analysis:
|
|
|
500
556
|
error_results = 0
|
|
501
557
|
recovered_errors = 0
|
|
502
558
|
pending_error = False
|
|
559
|
+
# Current Codex records what actually happened as item_completed events:
|
|
560
|
+
# FileChange for edits, CommandExecution (status/exit_code) for commands.
|
|
561
|
+
codex_file_changes = 0
|
|
562
|
+
codex_failed_commands = 0
|
|
563
|
+
codex_recovered_commands = 0
|
|
564
|
+
codex_pending_failure = False
|
|
503
565
|
text_chars = 0
|
|
504
566
|
tool_signatures: Counter[str] = Counter()
|
|
505
567
|
text_messages: Counter[str] = Counter()
|
|
@@ -513,8 +575,9 @@ def analyze(path: Path, discovered_source: str | None = None) -> Analysis:
|
|
|
513
575
|
text = raw.decode("utf-8", errors="replace")
|
|
514
576
|
for name, pattern in _SECRET_PATTERNS.items():
|
|
515
577
|
matches = pattern.findall(text)
|
|
516
|
-
|
|
517
|
-
|
|
578
|
+
validator = _SECRET_VALIDATORS.get(name)
|
|
579
|
+
if validator is not None:
|
|
580
|
+
matches = [m for m in matches if validator(m)]
|
|
518
581
|
secrets[name] += len(matches)
|
|
519
582
|
email_count += len(_EMAIL_PATTERN.findall(text))
|
|
520
583
|
phone_count += len(_PHONE_PATTERN.findall(text))
|
|
@@ -567,10 +630,17 @@ def analyze(path: Path, discovered_source: str | None = None) -> Analysis:
|
|
|
567
630
|
name = name if isinstance(name, str) else payload_type
|
|
568
631
|
tool_names.add(name)
|
|
569
632
|
edit_calls += int(name in _EDIT_TOOL_NAMES)
|
|
633
|
+
# function_call carries `arguments`; custom_tool_call
|
|
634
|
+
# (e.g. `exec`) carries `input` instead.
|
|
635
|
+
call_args = payload.get("arguments")
|
|
636
|
+
if call_args is None:
|
|
637
|
+
call_args = payload.get("input")
|
|
638
|
+
if isinstance(call_args, str):
|
|
639
|
+
tool_names.update(_CODEX_NESTED_TOOL.findall(call_args))
|
|
570
640
|
try:
|
|
571
|
-
args_repr = json.dumps(
|
|
641
|
+
args_repr = json.dumps(call_args, sort_keys=True, default=str)[:500]
|
|
572
642
|
except (TypeError, ValueError):
|
|
573
|
-
args_repr = str(
|
|
643
|
+
args_repr = str(call_args)[:500]
|
|
574
644
|
tool_signatures[f"{name}:{args_repr}"] += 1
|
|
575
645
|
if pending_error:
|
|
576
646
|
recovered_errors += 1
|
|
@@ -585,6 +655,22 @@ def analyze(path: Path, discovered_source: str | None = None) -> Analysis:
|
|
|
585
655
|
is_error = isinstance(output, dict) and bool(output.get("error"))
|
|
586
656
|
error_results += int(is_error)
|
|
587
657
|
pending_error = pending_error or is_error
|
|
658
|
+
if event_type == "event_msg" and payload_type == "item_completed":
|
|
659
|
+
item = payload.get("item")
|
|
660
|
+
item_type = item.get("type") if isinstance(item, dict) else None
|
|
661
|
+
if item_type == "FileChange":
|
|
662
|
+
codex_file_changes += 1
|
|
663
|
+
elif item_type == "CommandExecution":
|
|
664
|
+
exit_code = item.get("exit_code")
|
|
665
|
+
failed = item.get("status") == "failed" or (
|
|
666
|
+
isinstance(exit_code, int) and exit_code != 0
|
|
667
|
+
)
|
|
668
|
+
if failed:
|
|
669
|
+
codex_failed_commands += 1
|
|
670
|
+
codex_pending_failure = True
|
|
671
|
+
elif codex_pending_failure:
|
|
672
|
+
codex_recovered_commands += 1
|
|
673
|
+
codex_pending_failure = False
|
|
588
674
|
elif source == "claude_code":
|
|
589
675
|
messages += int(event_type in {"user", "assistant"})
|
|
590
676
|
events = _claude_events(record)
|
|
@@ -604,6 +690,14 @@ def analyze(path: Path, discovered_source: str | None = None) -> Analysis:
|
|
|
604
690
|
error_results += events.error_results
|
|
605
691
|
pending_error = True
|
|
606
692
|
|
|
693
|
+
if source == "codex":
|
|
694
|
+
# Older rollouts only have tool names/outputs; current ones also have
|
|
695
|
+
# the authoritative events. Take whichever saw more, never both.
|
|
696
|
+
edit_calls = max(edit_calls, codex_file_changes)
|
|
697
|
+
if codex_failed_commands > error_results:
|
|
698
|
+
error_results = codex_failed_commands
|
|
699
|
+
recovered_errors = codex_recovered_commands
|
|
700
|
+
|
|
607
701
|
secret_total = sum(secrets.values())
|
|
608
702
|
if secret_total:
|
|
609
703
|
privacy_risk = "blocked"
|
|
@@ -750,15 +844,20 @@ def redact_trajectory(path: Path) -> RedactionResult:
|
|
|
750
844
|
line_count += 1
|
|
751
845
|
text = raw.decode("utf-8", errors="replace")
|
|
752
846
|
for name, pattern in _SECRET_PATTERNS.items():
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
847
|
+
validator = _SECRET_VALIDATORS.get(name)
|
|
848
|
+
if validator is not None:
|
|
849
|
+
|
|
850
|
+
def _sub_validated(
|
|
851
|
+
match: re.Match[str],
|
|
852
|
+
_name: str = name,
|
|
853
|
+
_valid: Callable[[str], bool] = validator,
|
|
854
|
+
) -> str:
|
|
855
|
+
if _valid(match.group(0)):
|
|
856
|
+
categories.add(_name)
|
|
857
|
+
return f"[REDACTED_{_name.upper()}]"
|
|
759
858
|
return match.group(0)
|
|
760
859
|
|
|
761
|
-
text = pattern.sub(
|
|
860
|
+
text = pattern.sub(_sub_validated, text)
|
|
762
861
|
continue
|
|
763
862
|
if pattern.search(text):
|
|
764
863
|
categories.add(name)
|
|
@@ -776,8 +875,9 @@ def redact_trajectory(path: Path) -> RedactionResult:
|
|
|
776
875
|
|
|
777
876
|
for name, pattern in _SECRET_PATTERNS.items():
|
|
778
877
|
matches = pattern.findall(text)
|
|
779
|
-
|
|
780
|
-
|
|
878
|
+
validator = _SECRET_VALIDATORS.get(name)
|
|
879
|
+
if validator is not None:
|
|
880
|
+
matches = [m for m in matches if validator(m)]
|
|
781
881
|
residual_secrets[name] += len(matches)
|
|
782
882
|
residual_email += len(_EMAIL_PATTERN.findall(text))
|
|
783
883
|
residual_phone += len(_PHONE_PATTERN.findall(text))
|
|
@@ -906,3 +1006,71 @@ def content_simhash(path: Path) -> int:
|
|
|
906
1006
|
def hamming_distance(a: int, b: int) -> int:
|
|
907
1007
|
"""Number of differing bits between two fingerprints."""
|
|
908
1008
|
return bin(a ^ b).count("1")
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
# Per-capture identifiers and opaque blobs that carry no conversational content
|
|
1012
|
+
# and would only dilute overlap between two copies of the same session.
|
|
1013
|
+
_SKETCH_SKIP_KEYS = frozenset(
|
|
1014
|
+
{"id", "tool_use_id", "call_id", "signature", "encrypted_content", "uuid", "parentUuid", "timestamp"}
|
|
1015
|
+
)
|
|
1016
|
+
_SKETCH_WORD_RE = re.compile(r"\w+")
|
|
1017
|
+
_SKETCH_SHINGLE_WORDS = 5
|
|
1018
|
+
# Keep a shingle when its hash is 0 mod this value. Content-defined sampling
|
|
1019
|
+
# (unlike SimHash or bottom-k) keeps the same shingles from any excerpt of a
|
|
1020
|
+
# session, so a chunk, a truncated copy, or a lightly-edited copy still shares
|
|
1021
|
+
# most of its sampled shingles with the original.
|
|
1022
|
+
_SKETCH_SAMPLE_MODULUS = 8
|
|
1023
|
+
_SKETCH_MAX_HASHES = 20000
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _sketch_strings(value: object, out: list[str]) -> None:
|
|
1027
|
+
if isinstance(value, str):
|
|
1028
|
+
out.append(value)
|
|
1029
|
+
elif isinstance(value, list):
|
|
1030
|
+
for item in value:
|
|
1031
|
+
_sketch_strings(item, out)
|
|
1032
|
+
elif isinstance(value, dict):
|
|
1033
|
+
for key, item in value.items():
|
|
1034
|
+
if key not in _SKETCH_SKIP_KEYS:
|
|
1035
|
+
_sketch_strings(item, out)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def content_sketch(path: Path) -> list[int]:
|
|
1039
|
+
"""Sampled word-shingle hashes over message text AND tool calls/results.
|
|
1040
|
+
|
|
1041
|
+
Worker-only, companion to content_simhash: SimHash only matches
|
|
1042
|
+
near-identical whole sessions, so a moderately-edited copy, a chunk split
|
|
1043
|
+
out of a session, or a truncated re-upload slips past it. Overlap between
|
|
1044
|
+
two sketches estimates how much of one session's content appears in the
|
|
1045
|
+
other regardless of size or order. Hashes only -- not reversible to text.
|
|
1046
|
+
Returned sorted, deduplicated, as signed 64-bit ints (Postgres bigint).
|
|
1047
|
+
"""
|
|
1048
|
+
sampled: set[int] = set()
|
|
1049
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
1050
|
+
for line in handle:
|
|
1051
|
+
try:
|
|
1052
|
+
record = json.loads(line)
|
|
1053
|
+
except json.JSONDecodeError:
|
|
1054
|
+
continue
|
|
1055
|
+
if not isinstance(record, dict):
|
|
1056
|
+
continue
|
|
1057
|
+
strings: list[str] = []
|
|
1058
|
+
if record.get("type") == "response_item" and isinstance(record.get("payload"), dict):
|
|
1059
|
+
_sketch_strings(record["payload"], strings)
|
|
1060
|
+
elif record.get("type") in {"user", "assistant"} and isinstance(record.get("message"), dict):
|
|
1061
|
+
_sketch_strings(record["message"].get("content"), strings)
|
|
1062
|
+
if not strings:
|
|
1063
|
+
continue
|
|
1064
|
+
normalized = _TIMESTAMP_RE.sub(" ", "\n".join(strings).lower())
|
|
1065
|
+
normalized = _UUID_RE.sub(" ", normalized)
|
|
1066
|
+
words = _SKETCH_WORD_RE.findall(normalized)
|
|
1067
|
+
for index in range(len(words) - _SKETCH_SHINGLE_WORDS + 1):
|
|
1068
|
+
shingle = " ".join(words[index : index + _SKETCH_SHINGLE_WORDS])
|
|
1069
|
+
value = int.from_bytes(
|
|
1070
|
+
hashlib.blake2b(shingle.encode("utf-8"), digest_size=8).digest(), "big", signed=True
|
|
1071
|
+
)
|
|
1072
|
+
if value % _SKETCH_SAMPLE_MODULUS == 0:
|
|
1073
|
+
sampled.add(value)
|
|
1074
|
+
# Cap by smallest values so every capped sketch is a consistent prefix of
|
|
1075
|
+
# its full sketch; the server compares only below the lower of two maxima.
|
|
1076
|
+
return sorted(sampled)[:_SKETCH_MAX_HASHES]
|
|
@@ -16,12 +16,25 @@ from reasonwork_exchange.cli import sync as _sync
|
|
|
16
16
|
from reasonwork_exchange.cli import update as _update
|
|
17
17
|
from reasonwork_exchange.cli import upload as _upload
|
|
18
18
|
|
|
19
|
+
_QUICK_START = (
|
|
20
|
+
"[bold]Quick start[/bold]\n\n"
|
|
21
|
+
"1. [bold]reasonwork login[/bold] paste the token from reasonwork.ai/settings (input is hidden)\n\n"
|
|
22
|
+
"2. [bold]reasonwork scan[/bold] list local sessions and their privacy checks; nothing is uploaded\n\n"
|
|
23
|
+
"3. [bold]reasonwork upload --path FILE --confirm-rights[/bold] submit one session, or\n\n"
|
|
24
|
+
" [bold]reasonwork sync[/bold] submit new sessions, following your sharing mode\n\n"
|
|
25
|
+
"4. [bold]reasonwork config show[/bold] check what's shared; "
|
|
26
|
+
"[bold]reasonwork config set --sharing-mode ask[/bold] to be asked first\n\n"
|
|
27
|
+
"Run [bold]reasonwork COMMAND -h[/bold] for a command's options and an example."
|
|
28
|
+
)
|
|
29
|
+
|
|
19
30
|
app = typer.Typer(
|
|
20
31
|
name="reasonwork",
|
|
21
|
-
help="Discover, inspect, and submit Codex
|
|
32
|
+
help="Discover, inspect, and submit Claude Code, Codex, and Cursor sessions to Reasonwork.",
|
|
33
|
+
epilog=_QUICK_START,
|
|
22
34
|
add_completion=False,
|
|
23
35
|
rich_markup_mode="rich",
|
|
24
36
|
no_args_is_help=True,
|
|
37
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
25
38
|
)
|
|
26
39
|
|
|
27
40
|
app.command(name="login", epilog=_login._EPILOG)(_login.login)
|
|
@@ -11,7 +11,20 @@ from reasonwork_exchange.config import VALID_SHARING_MODES, ConsentConfigStore
|
|
|
11
11
|
from reasonwork_exchange.errors import ValidationError
|
|
12
12
|
from reasonwork_exchange.output import JsonOpt, emit_success, handle_errors
|
|
13
13
|
|
|
14
|
-
_EPILOG =
|
|
14
|
+
_EPILOG = (
|
|
15
|
+
"sharing modes:\n\n"
|
|
16
|
+
" all automatically upload every new session from all agents (Claude Code, Codex, Cursor) "
|
|
17
|
+
"in all projects on this machine, without asking\n\n"
|
|
18
|
+
" ask list what would be uploaded and ask before each sync (default)\n\n"
|
|
19
|
+
" none never upload\n\n"
|
|
20
|
+
"example: reasonwork config set --sharing-mode ask"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_MODE_SUMMARY = {
|
|
24
|
+
"all": "every new session from all agents and all projects on this machine is uploaded automatically, without asking",
|
|
25
|
+
"ask": "you'll be shown what would be uploaded and asked before each sync",
|
|
26
|
+
"none": "nothing is uploaded",
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
config_app = typer.Typer(name="config", help="Inspect and adjust local sharing consent.", no_args_is_help=True)
|
|
17
30
|
|
|
@@ -23,7 +36,7 @@ def show(json_: JsonOpt = False) -> int:
|
|
|
23
36
|
return emit_success(
|
|
24
37
|
json_,
|
|
25
38
|
[
|
|
26
|
-
f"sharing_mode: {config.sharing_mode}",
|
|
39
|
+
f"sharing_mode: {config.sharing_mode} ({_MODE_SUMMARY.get(config.sharing_mode, '')})",
|
|
27
40
|
f"excluded_sources: {', '.join(config.excluded_sources) or '(none)'}",
|
|
28
41
|
f"excluded_paths: {', '.join(config.excluded_paths) or '(none)'}",
|
|
29
42
|
],
|
|
@@ -37,17 +50,33 @@ def show(json_: JsonOpt = False) -> int:
|
|
|
37
50
|
|
|
38
51
|
@handle_errors
|
|
39
52
|
def set_mode(
|
|
40
|
-
sharing_mode: Annotated[
|
|
53
|
+
sharing_mode: Annotated[
|
|
54
|
+
str,
|
|
55
|
+
typer.Option(
|
|
56
|
+
"--sharing-mode",
|
|
57
|
+
help=(
|
|
58
|
+
"all: upload every new session from all agents (Claude Code, Codex, Cursor) and all "
|
|
59
|
+
"projects on this machine automatically, without asking. ask: ask before each sync "
|
|
60
|
+
"(default). none: never upload."
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
],
|
|
41
64
|
json_: JsonOpt = False,
|
|
42
65
|
) -> int:
|
|
43
|
-
"""Set the sharing mode: all, ask, or none."""
|
|
66
|
+
"""Set the sharing mode: all (automatic, every agent and project), ask, or none."""
|
|
44
67
|
if sharing_mode not in VALID_SHARING_MODES:
|
|
45
68
|
raise ValidationError(
|
|
46
69
|
f"sharing mode must be one of {', '.join(sorted(VALID_SHARING_MODES))}", code="invalid_sharing_mode"
|
|
47
70
|
)
|
|
48
71
|
store = ConsentConfigStore()
|
|
49
72
|
store.write(replace(store.read(), sharing_mode=sharing_mode))
|
|
50
|
-
|
|
73
|
+
lines = [f"sharing_mode set to {sharing_mode}: {_MODE_SUMMARY[sharing_mode]}."]
|
|
74
|
+
if sharing_mode == "all":
|
|
75
|
+
lines.append(
|
|
76
|
+
"Exclude anything you don't want shared with `reasonwork config exclude --path DIR` "
|
|
77
|
+
"or `--source NAME`."
|
|
78
|
+
)
|
|
79
|
+
return emit_success(json_, lines, {"sharing_mode": sharing_mode})
|
|
51
80
|
|
|
52
81
|
|
|
53
82
|
@handle_errors
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""`reasonwork login` — verify and store a contributor token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from reasonwork_exchange.errors import ValidationError
|
|
12
|
+
from reasonwork_exchange.output import JsonOpt, emit_success, handle_errors
|
|
13
|
+
|
|
14
|
+
_EPILOG = (
|
|
15
|
+
"example: reasonwork login (paste your token when asked; it isn't shown or saved in shell history)\n\n"
|
|
16
|
+
"scripts: REASONWORK_TOKEN=nxc_xxx reasonwork login"
|
|
17
|
+
)
|
|
18
|
+
TOKEN_ENV = "REASONWORK_TOKEN"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _interactive() -> bool:
|
|
22
|
+
return sys.stdin.isatty()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_token(flag: str | None) -> str:
|
|
26
|
+
"""--token, else $REASONWORK_TOKEN, else a hidden prompt on a terminal."""
|
|
27
|
+
token = (flag or os.environ.get(TOKEN_ENV) or "").strip()
|
|
28
|
+
if token:
|
|
29
|
+
return token
|
|
30
|
+
if not _interactive():
|
|
31
|
+
raise ValidationError(
|
|
32
|
+
"no token provided",
|
|
33
|
+
code="token_required",
|
|
34
|
+
hint=f"run `reasonwork login` in a terminal, or set {TOKEN_ENV}",
|
|
35
|
+
)
|
|
36
|
+
return typer.prompt("Paste your token (from reasonwork.ai/settings)", hide_input=True).strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@handle_errors
|
|
40
|
+
def login(
|
|
41
|
+
token: Annotated[
|
|
42
|
+
str | None,
|
|
43
|
+
typer.Option(
|
|
44
|
+
"--token",
|
|
45
|
+
help=(
|
|
46
|
+
"The nxc_ token from reasonwork.ai/settings. Prefer leaving this out: "
|
|
47
|
+
"you'll be asked for it with the input hidden, so it doesn't end up in "
|
|
48
|
+
f"shell history. Scripts can set {TOKEN_ENV} instead."
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
] = None,
|
|
52
|
+
dry_run: Annotated[
|
|
53
|
+
bool, typer.Option("--dry-run", help="Verify the token but do not write the contributor auth file.")
|
|
54
|
+
] = False,
|
|
55
|
+
json_: JsonOpt = False,
|
|
56
|
+
) -> int:
|
|
57
|
+
"""Sign in: verify your contributor token and store it, or preview under --dry-run."""
|
|
58
|
+
from reasonwork_exchange.auth import ContributorAuthStore
|
|
59
|
+
from reasonwork_exchange.client import ContributeClient
|
|
60
|
+
|
|
61
|
+
token = _resolve_token(token)
|
|
62
|
+
identity = ContributeClient(token).whoami()
|
|
63
|
+
store = ContributorAuthStore()
|
|
64
|
+
if not dry_run:
|
|
65
|
+
store.write(token)
|
|
66
|
+
email = str(identity.get("email", ""))
|
|
67
|
+
return emit_success(
|
|
68
|
+
json_,
|
|
69
|
+
(
|
|
70
|
+
[f"[dry-run] verified contributor {email}; token file was not written."]
|
|
71
|
+
if dry_run
|
|
72
|
+
else [f"Logged in as contributor {email}.", f"Token stored securely for your user at {store.path}."]
|
|
73
|
+
),
|
|
74
|
+
{"logged_in": not dry_run, "verified": True, "dry_run": dry_run, "email": email, "path": str(store.path)},
|
|
75
|
+
)
|
|
@@ -17,7 +17,7 @@ def scan(
|
|
|
17
17
|
limit: Annotated[int, typer.Option("--limit", min=1, max=500, help="Newest candidates to inspect, per source.")] = 20,
|
|
18
18
|
json_: JsonOpt = False,
|
|
19
19
|
) -> int:
|
|
20
|
-
"""Discover local Codex and
|
|
20
|
+
"""Discover local Claude Code, Codex, and Cursor sessions and show metadata-only checks."""
|
|
21
21
|
from reasonwork_exchange.analyzer import analyze
|
|
22
22
|
from reasonwork_exchange.discovery import discover
|
|
23
23
|
from reasonwork_exchange.state import SubmissionLedger
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
"""`reasonwork login` — verify and store a contributor token."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Annotated
|
|
6
|
-
|
|
7
|
-
import typer
|
|
8
|
-
|
|
9
|
-
from reasonwork_exchange.output import JsonOpt, emit_success, handle_errors
|
|
10
|
-
|
|
11
|
-
_EPILOG = "example: reasonwork login --token nxc_xxx --dry-run"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@handle_errors
|
|
15
|
-
def login(
|
|
16
|
-
token: Annotated[str, typer.Option("--token", help="The nxc_ token created in the exchange's /settings page.")],
|
|
17
|
-
dry_run: Annotated[
|
|
18
|
-
bool, typer.Option("--dry-run", help="Verify the token but do not write the contributor auth file.")
|
|
19
|
-
] = False,
|
|
20
|
-
json_: JsonOpt = False,
|
|
21
|
-
) -> int:
|
|
22
|
-
"""Verify the token and store it, or preview under --dry-run."""
|
|
23
|
-
from reasonwork_exchange.auth import ContributorAuthStore
|
|
24
|
-
from reasonwork_exchange.client import ContributeClient
|
|
25
|
-
|
|
26
|
-
identity = ContributeClient(token).whoami()
|
|
27
|
-
store = ContributorAuthStore()
|
|
28
|
-
if not dry_run:
|
|
29
|
-
store.write(token)
|
|
30
|
-
email = str(identity.get("email", ""))
|
|
31
|
-
return emit_success(
|
|
32
|
-
json_,
|
|
33
|
-
(
|
|
34
|
-
[f"[dry-run] verified contributor {email}; token file was not written."]
|
|
35
|
-
if dry_run
|
|
36
|
-
else [f"Logged in as contributor {email}.", f"Token stored securely for your user at {store.path}."]
|
|
37
|
-
),
|
|
38
|
-
{"logged_in": not dry_run, "verified": True, "dry_run": dry_run, "email": email, "path": str(store.path)},
|
|
39
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|