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.
- reasonwork-0.4.0.dist-info/METADATA +144 -0
- reasonwork-0.4.0.dist-info/RECORD +25 -0
- reasonwork-0.4.0.dist-info/WHEEL +5 -0
- reasonwork-0.4.0.dist-info/entry_points.txt +2 -0
- reasonwork-0.4.0.dist-info/licenses/LICENSE +52 -0
- reasonwork-0.4.0.dist-info/top_level.txt +1 -0
- reasonwork_exchange/__init__.py +3 -0
- reasonwork_exchange/__main__.py +6 -0
- reasonwork_exchange/analyzer.py +908 -0
- reasonwork_exchange/auth.py +47 -0
- reasonwork_exchange/cli/__init__.py +55 -0
- reasonwork_exchange/cli/config.py +89 -0
- reasonwork_exchange/cli/login.py +39 -0
- reasonwork_exchange/cli/scan.py +99 -0
- reasonwork_exchange/cli/schedule.py +140 -0
- reasonwork_exchange/cli/sync.py +317 -0
- reasonwork_exchange/cli/update.py +177 -0
- reasonwork_exchange/cli/upload.py +95 -0
- reasonwork_exchange/client.py +273 -0
- reasonwork_exchange/config.py +70 -0
- reasonwork_exchange/discovery.py +59 -0
- reasonwork_exchange/errors.py +59 -0
- reasonwork_exchange/output.py +73 -0
- reasonwork_exchange/state.py +249 -0
- reasonwork_exchange/version_check.py +63 -0
|
@@ -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"]
|