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.
Files changed (34) hide show
  1. {reasonwork-0.6.0/src/reasonwork.egg-info → reasonwork-0.6.2}/PKG-INFO +1 -1
  2. {reasonwork-0.6.0 → reasonwork-0.6.2/src/reasonwork.egg-info}/PKG-INFO +1 -1
  3. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/__init__.py +1 -1
  4. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/analyzer.py +182 -14
  5. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/__init__.py +14 -1
  6. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/config.py +34 -5
  7. reasonwork-0.6.2/src/reasonwork_exchange/cli/login.py +75 -0
  8. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/scan.py +1 -1
  9. reasonwork-0.6.0/src/reasonwork_exchange/cli/login.py +0 -39
  10. {reasonwork-0.6.0 → reasonwork-0.6.2}/LICENSE +0 -0
  11. {reasonwork-0.6.0 → reasonwork-0.6.2}/MANIFEST.in +0 -0
  12. {reasonwork-0.6.0 → reasonwork-0.6.2}/PYPI_README.md +0 -0
  13. {reasonwork-0.6.0 → reasonwork-0.6.2}/pyproject.toml +0 -0
  14. {reasonwork-0.6.0 → reasonwork-0.6.2}/setup.cfg +0 -0
  15. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/SOURCES.txt +0 -0
  16. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/dependency_links.txt +0 -0
  17. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/entry_points.txt +0 -0
  18. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/requires.txt +0 -0
  19. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork.egg-info/top_level.txt +0 -0
  20. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/__main__.py +0 -0
  21. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/auth.py +0 -0
  22. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/hook.py +0 -0
  23. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/schedule.py +0 -0
  24. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/sync.py +0 -0
  25. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/update.py +0 -0
  26. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/cli/upload.py +0 -0
  27. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/client.py +0 -0
  28. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/config.py +0 -0
  29. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/discovery.py +0 -0
  30. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/errors.py +0 -0
  31. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/filesystem.py +0 -0
  32. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/output.py +0 -0
  33. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/state.py +0 -0
  34. {reasonwork-0.6.0 → reasonwork-0.6.2}/src/reasonwork_exchange/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reasonwork
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Reasonwork CLI: safely contribute Codex, Claude Code, and Cursor trajectories to Reasonwork.
5
5
  Author: ReasonworkAI, Inc.
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reasonwork
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Reasonwork CLI: safely contribute Codex, Claude Code, and Cursor trajectories to Reasonwork.
5
5
  Author: ReasonworkAI, Inc.
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,3 +1,3 @@
1
1
  """Public package for the standalone Reasonwork CLI (by ReasonworkAI)."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.2"
@@ -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{4}\s\d{4}\s\d{4}\b"),
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
- if name == "credit_card":
517
- matches = [m for m in matches if _luhn_valid(m)]
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(payload.get("arguments"), sort_keys=True, default=str)[:500]
641
+ args_repr = json.dumps(call_args, sort_keys=True, default=str)[:500]
572
642
  except (TypeError, ValueError):
573
- args_repr = str(payload.get("arguments"))[:500]
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
- if name == "credit_card":
754
-
755
- def _sub_card(match: re.Match[str]) -> str:
756
- if _luhn_valid(match.group(0)):
757
- categories.add("credit_card")
758
- return "[REDACTED_CREDIT_CARD]"
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(_sub_card, text)
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
- if name == "credit_card":
780
- matches = [m for m in matches if _luhn_valid(m)]
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 or Claude Code trajectories.",
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 = "example: reasonwork config set --sharing-mode ask"
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[str, typer.Option("--sharing-mode", help="all, ask, or none.")],
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
- return emit_success(json_, [f"sharing_mode set to {sharing_mode}."], {"sharing_mode": sharing_mode})
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 Claude Code sessions and show metadata-only checks."""
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