reasonwork 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,47 @@
1
+ """Owner-only local storage for the contributor upload token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from reasonwork_exchange.errors import AuthError, ValidationError
10
+
11
+
12
+ class ContributorAuthStore:
13
+ """Persist a raw nxc_ contributor token separately from internal PATs."""
14
+
15
+ def __init__(self, path: Path | None = None) -> None:
16
+ """Use an explicit path or the contributor-only config location."""
17
+ self.path = path or (Path.home() / ".config" / "reasonwork" / "exchange-auth.json")
18
+
19
+ def write(self, token: str) -> None:
20
+ """Write one validated contributor token with mode 600."""
21
+ if not token.startswith("nxc_"):
22
+ raise ValidationError("expected an nxc_ contributor token", code="invalid_contributor_token")
23
+ self.path.parent.mkdir(parents=True, exist_ok=True)
24
+ fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
25
+ with os.fdopen(fd, "w") as handle:
26
+ os.fchmod(handle.fileno(), 0o600)
27
+ handle.write(json.dumps({"schema_version": 1, "token": token}, indent=2) + "\n")
28
+
29
+ def read(self) -> str:
30
+ """Read the env override or stored token."""
31
+ env_token = os.environ.get("REASONWORK_EXCHANGE_TOKEN")
32
+ if env_token:
33
+ return env_token
34
+ try:
35
+ token = json.loads(self.path.read_text())["token"]
36
+ except (OSError, KeyError, json.JSONDecodeError) as exc:
37
+ raise AuthError(
38
+ "not logged in as a contributor",
39
+ code="no_contributor_token",
40
+ hint=(
41
+ "create a token at https://ce-staging.collinear.dev/settings, "
42
+ "then run `reasonwork login --token nxc_…`"
43
+ ),
44
+ ) from exc
45
+ if not isinstance(token, str) or not token.startswith("nxc_"):
46
+ raise AuthError("stored contributor token is invalid", code="invalid_contributor_token")
47
+ return token
@@ -0,0 +1,55 @@
1
+ """Command tree for the standalone Reasonwork CLI (by ReasonworkAI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from reasonwork_exchange import __version__
10
+ from reasonwork_exchange.cli import config as _config
11
+ from reasonwork_exchange.cli import login as _login
12
+ from reasonwork_exchange.cli import scan as _scan
13
+ from reasonwork_exchange.cli import schedule as _schedule
14
+ from reasonwork_exchange.cli import sync as _sync
15
+ from reasonwork_exchange.cli import update as _update
16
+ from reasonwork_exchange.cli import upload as _upload
17
+
18
+ app = typer.Typer(
19
+ name="reasonwork",
20
+ help="Discover, inspect, and submit Codex or Claude Code trajectories.",
21
+ add_completion=False,
22
+ rich_markup_mode="rich",
23
+ no_args_is_help=True,
24
+ )
25
+
26
+ app.command(name="login", epilog=_login._EPILOG)(_login.login)
27
+ app.command(name="scan", epilog=_scan._EPILOG)(_scan.scan)
28
+ app.command(name="sync", epilog=_sync._EPILOG)(_sync.sync)
29
+ app.command(name="upload", epilog=_upload._EPILOG)(_upload.upload)
30
+ app.command(name="update", epilog=_update._EPILOG)(_update.update)
31
+ app.add_typer(_config.config_app)
32
+ app.add_typer(_schedule.schedule_app)
33
+
34
+
35
+ def _version(value: bool) -> None:
36
+ if value:
37
+ typer.echo(__version__)
38
+ raise typer.Exit()
39
+
40
+
41
+ @app.callback()
42
+ def main(
43
+ version: Annotated[
44
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the installed CLI version.")
45
+ ] = False,
46
+ ) -> None:
47
+ """Run the contributor exchange CLI."""
48
+
49
+
50
+ def entrypoint() -> None:
51
+ """Console-script entry point."""
52
+ app()
53
+
54
+
55
+ __all__ = ["app", "entrypoint", "main"]
@@ -0,0 +1,89 @@
1
+ """`reasonwork config` — inspect and adjust local sharing consent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from reasonwork_exchange.config import VALID_SHARING_MODES, ConsentConfigStore
11
+ from reasonwork_exchange.errors import ValidationError
12
+ from reasonwork_exchange.output import JsonOpt, emit_success, handle_errors
13
+
14
+ _EPILOG = "example: reasonwork config set --sharing-mode ask"
15
+
16
+ config_app = typer.Typer(name="config", help="Inspect and adjust local sharing consent.", no_args_is_help=True)
17
+
18
+
19
+ @handle_errors
20
+ def show(json_: JsonOpt = False) -> int:
21
+ """Print the current consent settings."""
22
+ config = ConsentConfigStore().read()
23
+ return emit_success(
24
+ json_,
25
+ [
26
+ f"sharing_mode: {config.sharing_mode}",
27
+ f"excluded_sources: {', '.join(config.excluded_sources) or '(none)'}",
28
+ f"excluded_paths: {', '.join(config.excluded_paths) or '(none)'}",
29
+ ],
30
+ {
31
+ "sharing_mode": config.sharing_mode,
32
+ "excluded_sources": config.excluded_sources,
33
+ "excluded_paths": config.excluded_paths,
34
+ },
35
+ )
36
+
37
+
38
+ @handle_errors
39
+ def set_mode(
40
+ sharing_mode: Annotated[str, typer.Option("--sharing-mode", help="all, ask, or none.")],
41
+ json_: JsonOpt = False,
42
+ ) -> int:
43
+ """Set the sharing mode: all, ask, or none."""
44
+ if sharing_mode not in VALID_SHARING_MODES:
45
+ raise ValidationError(
46
+ f"sharing mode must be one of {', '.join(sorted(VALID_SHARING_MODES))}", code="invalid_sharing_mode"
47
+ )
48
+ store = ConsentConfigStore()
49
+ 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})
51
+
52
+
53
+ @handle_errors
54
+ def exclude(
55
+ source: Annotated[str | None, typer.Option("--source", help="Source name to exclude, e.g. codex.")] = None,
56
+ path: Annotated[str | None, typer.Option("--path", help="Path prefix to exclude.")] = None,
57
+ remove: Annotated[bool, typer.Option("--remove", help="Remove instead of add the given exclusion.")] = False,
58
+ json_: JsonOpt = False,
59
+ ) -> int:
60
+ """Add or remove a source or path exclusion from local sharing."""
61
+ if not source and not path:
62
+ raise ValidationError("pass --source or --path", code="missing_exclusion_target")
63
+ store = ConsentConfigStore()
64
+ config = store.read()
65
+ excluded_sources = list(config.excluded_sources)
66
+ excluded_paths = list(config.excluded_paths)
67
+ if source:
68
+ if remove:
69
+ excluded_sources = [item for item in excluded_sources if item != source]
70
+ elif source not in excluded_sources:
71
+ excluded_sources.append(source)
72
+ if path:
73
+ if remove:
74
+ excluded_paths = [item for item in excluded_paths if item != path]
75
+ elif path not in excluded_paths:
76
+ excluded_paths.append(path)
77
+ store.write(replace(config, excluded_sources=excluded_sources, excluded_paths=excluded_paths))
78
+ return emit_success(
79
+ json_,
80
+ [f"{'removed' if remove else 'added'} exclusion."],
81
+ {"excluded_sources": excluded_sources, "excluded_paths": excluded_paths},
82
+ )
83
+
84
+
85
+ config_app.command(name="show")(show)
86
+ config_app.command(name="set", epilog=_EPILOG)(set_mode)
87
+ config_app.command(name="exclude")(exclude)
88
+
89
+ __all__ = ["config_app"]
@@ -0,0 +1,39 @@
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 at {store.path} (mode 600)."]
37
+ ),
38
+ {"logged_in": not dry_run, "verified": True, "dry_run": dry_run, "email": email, "path": str(store.path)},
39
+ )
@@ -0,0 +1,99 @@
1
+ """`reasonwork scan` — preview local trajectory candidates safely."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any
6
+
7
+ import typer
8
+ from rich.table import Table
9
+
10
+ from reasonwork_exchange.output import JsonOpt, emit, handle_errors
11
+
12
+ _EPILOG = "example: reasonwork scan --limit 20"
13
+
14
+
15
+ @handle_errors
16
+ def scan(
17
+ limit: Annotated[int, typer.Option("--limit", min=1, max=500, help="Newest candidates to inspect, per source.")] = 20,
18
+ json_: JsonOpt = False,
19
+ ) -> int:
20
+ """Discover local Codex and Claude Code sessions and show metadata-only checks."""
21
+ from reasonwork_exchange.analyzer import analyze
22
+ from reasonwork_exchange.discovery import discover
23
+ from reasonwork_exchange.state import SubmissionLedger
24
+
25
+ ledger = SubmissionLedger()
26
+ results: list[dict[str, Any]] = []
27
+ for item in discover(limit_per_source=limit):
28
+ cached = ledger.cached_fingerprint(item.path)
29
+ cached_status = ledger.status_for_hash(cached.content_sha256) if cached else None
30
+ if cached_status is not None:
31
+ results.append(
32
+ {
33
+ "path": str(item.path),
34
+ "discovered_source": item.source,
35
+ "is_subagent": item.is_subagent,
36
+ "status": cached_status,
37
+ "skipped": True,
38
+ }
39
+ )
40
+ continue
41
+
42
+ # A live-captured session only grows by appending, so a large
43
+ # actively-growing file would otherwise fail cached_fingerprint (an
44
+ # exact mtime/size match) on every single poll, paying for a full
45
+ # secret/PII re-scan of the whole file each time. This reuses the
46
+ # same not-worth-reconsidering-yet growth threshold sync's
47
+ # _submit_one already applies before calling analyze() — scan is a
48
+ # read-only preview, so it can safely skip the same way sync does.
49
+ recorded_status = ledger.last_recorded_status(item.path)
50
+ if recorded_status is not None:
51
+ byte_size = item.path.stat().st_size
52
+ if ledger.growth_supersession(item.path, byte_size) is None:
53
+ results.append(
54
+ {
55
+ "path": str(item.path),
56
+ "discovered_source": item.source,
57
+ "is_subagent": item.is_subagent,
58
+ "status": recorded_status,
59
+ "skipped": True,
60
+ }
61
+ )
62
+ continue
63
+ try:
64
+ analysis = analyze(item.path, discovered_source=item.source).to_dict()
65
+ # Codex subagents have no path signal (see sync.py's _submit_one for the
66
+ # same merge) -- the analyzer's content-based determination is
67
+ # authoritative whenever it says True.
68
+ analysis["is_subagent"] = item.is_subagent or analysis["is_subagent"]
69
+ results.append(
70
+ {"path": str(item.path), "discovered_source": item.source, **analysis}
71
+ )
72
+ except (OSError, ValueError) as exc:
73
+ results.append(
74
+ {
75
+ "path": str(item.path),
76
+ "discovered_source": item.source,
77
+ "is_subagent": item.is_subagent,
78
+ "error": str(exc),
79
+ "estimated_cents": 0,
80
+ }
81
+ )
82
+
83
+ table = Table(title="Local trajectory candidates")
84
+ for heading in ("Source", "Risk", "Messages", "Tool calls", "Session file"):
85
+ table.add_column(heading)
86
+ for row in results:
87
+ # Score/price are no longer computed locally at all (see analyzer.analyze()'s
88
+ # own docstring) -- only the server, after actual review, knows what a
89
+ # session is worth. Message/tool-call counts are still genuinely local
90
+ # signals worth previewing (e.g. spotting an empty or read-only session
91
+ # before bothering to submit it).
92
+ table.add_row(
93
+ str(row.get("source") or row["discovered_source"]),
94
+ str(row.get("privacy_risk") or "skipped"),
95
+ "—" if "message_count" not in row else str(row["message_count"]),
96
+ "—" if "tool_call_count" not in row else str(row["tool_call_count"]),
97
+ str(row["path"]),
98
+ )
99
+ return emit(json_, {"candidates": results, "total": len(results)}, human=table)
@@ -0,0 +1,140 @@
1
+ """`reasonwork schedule` — manage a background agent that runs `sync` on a timer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from reasonwork_exchange.errors import ValidationError
14
+ from reasonwork_exchange.output import JsonOpt, emit_success, handle_errors
15
+
16
+ _EPILOG = "example: reasonwork schedule install --interval-minutes 30"
17
+
18
+ _LABEL = "ai.reasonwork.contributor-exchange.sync"
19
+ _DEFAULT_INTERVAL_MINUTES = 10
20
+
21
+ schedule_app = typer.Typer(name="schedule", help="Manage automatic background sync.", no_args_is_help=True)
22
+
23
+
24
+ def _plist_path() -> Path:
25
+ return Path.home() / "Library" / "LaunchAgents" / f"{_LABEL}.plist"
26
+
27
+
28
+ def _log_path() -> Path:
29
+ return Path.home() / ".config" / "reasonwork" / "sync.log"
30
+
31
+
32
+ def _require_macos() -> None:
33
+ if platform.system() != "Darwin":
34
+ raise ValidationError(
35
+ "background scheduling is only wired up for macOS launchd",
36
+ code="unsupported_platform",
37
+ hint="on Linux, add a systemd timer or cron entry that runs 'reasonwork sync' on your own schedule",
38
+ )
39
+
40
+
41
+ def _render_plist(*, interval_minutes: int, python_executable: str) -> str:
42
+ return (
43
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
44
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
45
+ '<plist version="1.0">\n'
46
+ "<dict>\n"
47
+ " <key>Label</key>\n"
48
+ f" <string>{_LABEL}</string>\n"
49
+ " <key>ProgramArguments</key>\n"
50
+ " <array>\n"
51
+ f" <string>{python_executable}</string>\n"
52
+ " <string>-m</string>\n"
53
+ " <string>reasonwork_exchange</string>\n"
54
+ " <string>sync</string>\n"
55
+ " <string>--json</string>\n"
56
+ " </array>\n"
57
+ " <key>StartInterval</key>\n"
58
+ f" <integer>{interval_minutes * 60}</integer>\n"
59
+ " <key>StandardOutPath</key>\n"
60
+ f" <string>{_log_path()}</string>\n"
61
+ " <key>StandardErrorPath</key>\n"
62
+ f" <string>{_log_path()}</string>\n"
63
+ " <key>RunAtLoad</key>\n"
64
+ " <false/>\n"
65
+ "</dict>\n"
66
+ "</plist>\n"
67
+ )
68
+
69
+
70
+ @handle_errors
71
+ def install(
72
+ interval_minutes: Annotated[
73
+ int, typer.Option("--interval-minutes", min=1, max=1440, help="How often to run sync.")
74
+ ] = _DEFAULT_INTERVAL_MINUTES,
75
+ yes: Annotated[bool, typer.Option("--yes", help="Skip the confirmation prompt.")] = False,
76
+ json_: JsonOpt = False,
77
+ ) -> int:
78
+ """Install (or replace) the per-user launchd agent that runs sync on a timer."""
79
+ _require_macos()
80
+ if not yes:
81
+ typer.confirm(
82
+ f"Install a background agent that runs `sync` every {interval_minutes} minute(s)? "
83
+ "(only auto-uploads while sharing_mode=all; sharing_mode=ask sessions are left for interactive review)",
84
+ abort=True,
85
+ )
86
+ plist_path = _plist_path()
87
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
88
+ _log_path().parent.mkdir(parents=True, exist_ok=True)
89
+
90
+ if plist_path.exists():
91
+ subprocess.run(["launchctl", "unload", str(plist_path)], check=False, capture_output=True)
92
+
93
+ plist_path.write_text(_render_plist(interval_minutes=interval_minutes, python_executable=sys.executable))
94
+ subprocess.run(["launchctl", "load", str(plist_path)], check=True, capture_output=True)
95
+
96
+ return emit_success(
97
+ json_,
98
+ [f"Installed background sync every {interval_minutes} minute(s).", f"Logs: {_log_path()}"],
99
+ {"installed": True, "interval_minutes": interval_minutes, "plist_path": str(plist_path)},
100
+ )
101
+
102
+
103
+ @handle_errors
104
+ def uninstall(json_: JsonOpt = False) -> int:
105
+ """Remove the background sync agent, if one is installed."""
106
+ _require_macos()
107
+ plist_path = _plist_path()
108
+ if not plist_path.exists():
109
+ return emit_success(json_, ["No background sync agent was installed."], {"removed": False})
110
+ subprocess.run(["launchctl", "unload", str(plist_path)], check=False, capture_output=True)
111
+ plist_path.unlink()
112
+ return emit_success(json_, ["Removed background sync agent."], {"removed": True})
113
+
114
+
115
+ @handle_errors
116
+ def status(json_: JsonOpt = False) -> int:
117
+ """Report whether the background sync agent is installed and loaded."""
118
+ _require_macos()
119
+ plist_path = _plist_path()
120
+ installed = plist_path.exists()
121
+ loaded = False
122
+ if installed:
123
+ result = subprocess.run(["launchctl", "list", _LABEL], check=False, capture_output=True)
124
+ loaded = result.returncode == 0
125
+ lines = [f"installed: {installed}", f"loaded: {loaded}"]
126
+ if installed:
127
+ lines.append(f"plist: {plist_path}")
128
+ lines.append(f"log: {_log_path()}")
129
+ return emit_success(
130
+ json_,
131
+ lines,
132
+ {"installed": installed, "loaded": loaded, "plist_path": str(plist_path) if installed else None},
133
+ )
134
+
135
+
136
+ schedule_app.command(name="install", epilog=_EPILOG)(install)
137
+ schedule_app.command(name="uninstall")(uninstall)
138
+ schedule_app.command(name="status")(status)
139
+
140
+ __all__ = ["schedule_app"]