offset-terminal 0.8.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.
- offset/__init__.py +8 -0
- offset/__main__.py +133 -0
- offset/auth.py +291 -0
- offset/core/__init__.py +1 -0
- offset/core/agent.py +431 -0
- offset/core/audit.py +880 -0
- offset/core/branches.py +198 -0
- offset/core/bridge.py +1336 -0
- offset/core/collab.py +1766 -0
- offset/core/compaction.py +403 -0
- offset/core/context.py +214 -0
- offset/core/daemon.py +209 -0
- offset/core/decompose.py +915 -0
- offset/core/entries.py +144 -0
- offset/core/forge.py +886 -0
- offset/core/fsnapshot.py +682 -0
- offset/core/index.py +544 -0
- offset/core/jobs.py +851 -0
- offset/core/loopback.py +1067 -0
- offset/core/multimodel.py +402 -0
- offset/core/permissions.py +159 -0
- offset/core/scoring.py +656 -0
- offset/core/session.py +417 -0
- offset/core/settings.py +552 -0
- offset/core/snapshots.py +481 -0
- offset/core/speculate.py +591 -0
- offset/core/symbols.py +717 -0
- offset/core/tasks.py +579 -0
- offset/core/update.py +1164 -0
- offset/core/vcs.py +580 -0
- offset/core/workflow.py +489 -0
- offset/eggs/__init__.py +14 -0
- offset/eggs/catalogue.py +285 -0
- offset/eggs/engine.py +269 -0
- offset/providers/__init__.py +43 -0
- offset/providers/anthropic.py +163 -0
- offset/providers/auth.py +437 -0
- offset/providers/base.py +266 -0
- offset/providers/catalogue.py +525 -0
- offset/providers/google.py +334 -0
- offset/providers/mock.py +84 -0
- offset/providers/oauth.py +793 -0
- offset/providers/ollama.py +108 -0
- offset/providers/openai.py +244 -0
- offset/providers/opencode.py +228 -0
- offset/providers/registry.py +400 -0
- offset/providers/schema.py +305 -0
- offset/providers/sse.py +62 -0
- offset/providers/transport.py +120 -0
- offset/shell/__init__.py +5 -0
- offset/shell/app.py +979 -0
- offset/shell/commands.py +1256 -0
- offset/shell/consent.py +262 -0
- offset/shell/render.py +387 -0
- offset/tools/__init__.py +23 -0
- offset/tools/agents.py +516 -0
- offset/tools/ask.py +232 -0
- offset/tools/base.py +239 -0
- offset/tools/builtin.py +421 -0
- offset/tools/custom.py +278 -0
- offset/tools/debug/__init__.py +121 -0
- offset/tools/debug/adapters.py +376 -0
- offset/tools/debug/client.py +1188 -0
- offset/tools/debug/protocol.py +876 -0
- offset/tools/debug/tool.py +581 -0
- offset/tools/documents.py +780 -0
- offset/tools/github.py +438 -0
- offset/tools/lsp/__init__.py +83 -0
- offset/tools/lsp/client.py +1051 -0
- offset/tools/lsp/protocol.py +594 -0
- offset/tools/lsp/servers.py +601 -0
- offset/tools/lsp/tool.py +440 -0
- offset/tools/mcp/__init__.py +76 -0
- offset/tools/mcp/client.py +514 -0
- offset/tools/mcp/manager.py +903 -0
- offset/tools/mcp/marketplace.py +1046 -0
- offset/tools/mcp/transport.py +478 -0
- offset/tools/patch.py +861 -0
- offset/tools/plugins.py +905 -0
- offset/tools/retrieve.py +444 -0
- offset/tools/runtime.py +204 -0
- offset/tools/system.py +571 -0
- offset/tools/todo.py +406 -0
- offset/tools/walk.py +242 -0
- offset/tools/web/__init__.py +67 -0
- offset/tools/web/browser.py +373 -0
- offset/tools/web/cdp.py +1277 -0
- offset/tools/web/wsclient.py +618 -0
- offset/tools/websearch.py +270 -0
- offset/ui/__init__.py +11 -0
- offset/ui/anim.py +198 -0
- offset/ui/brutal.py +419 -0
- offset/ui/canvas.py +186 -0
- offset/ui/demo.py +251 -0
- offset/ui/ghost.py +601 -0
- offset/ui/theme.py +330 -0
- offset/ui/tokens.py +367 -0
- offset_terminal-0.8.0.dist-info/METADATA +497 -0
- offset_terminal-0.8.0.dist-info/RECORD +102 -0
- offset_terminal-0.8.0.dist-info/WHEEL +4 -0
- offset_terminal-0.8.0.dist-info/entry_points.txt +2 -0
- offset_terminal-0.8.0.dist-info/licenses/LICENSE +647 -0
offset/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""offset — a terminal coding agent that builds several answers at once.
|
|
2
|
+
|
|
3
|
+
Design rule for the whole package: the aesthetic is not a theme, it is the
|
|
4
|
+
construction method. Hard edges, flat fills, zero-blur shadows, no rounded
|
|
5
|
+
corners anywhere. See `offset.ui.tokens` for the single source of truth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.8.0"
|
offset/__main__.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _autoupdate() -> None:
|
|
10
|
+
"""Install a waiting update, then become the new version.
|
|
11
|
+
|
|
12
|
+
Deliberately quiet: it prints only when it actually does something, so the
|
|
13
|
+
overwhelmingly common case of "already current" costs the user no output
|
|
14
|
+
and no delay. It reads the cache the previous run's background check left
|
|
15
|
+
behind rather than the network, so an offline start is not paid for here.
|
|
16
|
+
|
|
17
|
+
Every failure is swallowed. A program that refuses to start because it
|
|
18
|
+
could not upgrade itself is worse than one running last week's build.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
from offset.core.update import autoupdate, reexec
|
|
22
|
+
except Exception:
|
|
23
|
+
return
|
|
24
|
+
try:
|
|
25
|
+
outcome = autoupdate(echo=lambda line: print(line, flush=True))
|
|
26
|
+
except Exception:
|
|
27
|
+
return
|
|
28
|
+
for line in outcome.report():
|
|
29
|
+
print(line, flush=True)
|
|
30
|
+
if outcome.acted:
|
|
31
|
+
reexec() # returns only if exec failed, in which case carry on
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: list[str] | None = None) -> int:
|
|
35
|
+
parser = argparse.ArgumentParser(prog="offset", description="terminal coding agent")
|
|
36
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
37
|
+
|
|
38
|
+
chat = sub.add_parser("chat", help="start an interactive session")
|
|
39
|
+
chat.add_argument("--model", default=None,
|
|
40
|
+
help="model id; defaults to model.default from config, else the scripted mock")
|
|
41
|
+
chat.add_argument("--workspace", default=".", help="directory the tools may touch")
|
|
42
|
+
chat.add_argument("--approve", default=None,
|
|
43
|
+
choices=("safe", "auto-edit", "yolo", "full"),
|
|
44
|
+
help="approval mode; defaults to the stored permission grant")
|
|
45
|
+
chat.add_argument("--resume", nargs="?", const="", default=None, metavar="ID",
|
|
46
|
+
help="carry on an earlier session; bare --resume takes the most recent")
|
|
47
|
+
chat.add_argument("--continue", dest="continue_", action="store_true",
|
|
48
|
+
help="carry on the most recent session")
|
|
49
|
+
|
|
50
|
+
sub.add_parser("login", help="sign in with your Google or GitHub account")
|
|
51
|
+
sub.add_parser("sync", help="sync Offset Plus subscription status from your account")
|
|
52
|
+
|
|
53
|
+
upgrade = sub.add_parser("upgrade", help="redeem a Gumroad licence key to unlock Offset Plus")
|
|
54
|
+
upgrade.add_argument("key", help="the licence key from your Gumroad receipt")
|
|
55
|
+
|
|
56
|
+
upd = sub.add_parser("update", help="check for a newer offset and install it")
|
|
57
|
+
upd.add_argument("--check", action="store_true",
|
|
58
|
+
help="only report whether an update exists")
|
|
59
|
+
|
|
60
|
+
dmn = sub.add_parser("daemon", help="run headless, for an editor or a remote client")
|
|
61
|
+
dmn.add_argument("--workspace", default=".", help="directory the tools may touch")
|
|
62
|
+
dmn.add_argument("--model", default=None, help="model id; defaults to the configured one")
|
|
63
|
+
dmn.add_argument("--listen", default="", metavar="ADDR",
|
|
64
|
+
help="bind TCP instead of a unix socket: 'tcp', 'host:port' or ':port'. "
|
|
65
|
+
"Anything other than loopback exposes the agent to that network")
|
|
66
|
+
dmn.add_argument("--idle", type=float, default=0.0, metavar="SECONDS",
|
|
67
|
+
help="exit after this long with no client connected; 0 never exits")
|
|
68
|
+
dmn.add_argument("--quiet", action="store_true", help="do not print the descriptor")
|
|
69
|
+
|
|
70
|
+
demo = sub.add_parser("demo", help="render the design system")
|
|
71
|
+
demo.add_argument("--once", action="store_true", help="print one frame and exit")
|
|
72
|
+
demo.add_argument("--time", type=float, default=2.4, help="timestamp of the frame to print")
|
|
73
|
+
demo.add_argument("--fps", type=float, default=24.0)
|
|
74
|
+
demo.add_argument("--width", type=int, default=None)
|
|
75
|
+
demo.add_argument("--height", type=int, default=None)
|
|
76
|
+
|
|
77
|
+
args = parser.parse_args(argv)
|
|
78
|
+
|
|
79
|
+
if args.cmd == "login":
|
|
80
|
+
from offset.auth import prompt_account_login
|
|
81
|
+
prompt_account_login()
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
if args.cmd == "sync":
|
|
85
|
+
from offset.auth import sync_command
|
|
86
|
+
return sync_command()
|
|
87
|
+
|
|
88
|
+
if args.cmd == "upgrade":
|
|
89
|
+
from offset.auth import verify_direct_license_key
|
|
90
|
+
return verify_direct_license_key(args.key)
|
|
91
|
+
|
|
92
|
+
if args.cmd == "update":
|
|
93
|
+
from offset.core.update import update_command
|
|
94
|
+
return update_command(check_only=args.check)
|
|
95
|
+
|
|
96
|
+
if args.cmd == "daemon":
|
|
97
|
+
from offset.core.daemon import command as daemon_command
|
|
98
|
+
return daemon_command(args)
|
|
99
|
+
|
|
100
|
+
if args.cmd == "demo":
|
|
101
|
+
from offset.ui import demo as demo_mod
|
|
102
|
+
|
|
103
|
+
if args.once:
|
|
104
|
+
print(demo_mod.frame(args.time, w=args.width, h=args.height))
|
|
105
|
+
return 0
|
|
106
|
+
return demo_mod.run(fps=args.fps)
|
|
107
|
+
|
|
108
|
+
if args.cmd in (None, "chat"):
|
|
109
|
+
# Before the shell, never during it: the modules a running session has
|
|
110
|
+
# already imported cannot be swapped underneath it, so an update
|
|
111
|
+
# applied mid-turn would leave half the program on the old version.
|
|
112
|
+
# A successful update re-executes, so the user gets the new build in
|
|
113
|
+
# the same invocation rather than being told to start again.
|
|
114
|
+
_autoupdate()
|
|
115
|
+
|
|
116
|
+
from offset.shell.app import main as chat_main
|
|
117
|
+
|
|
118
|
+
resume = getattr(args, "resume", None)
|
|
119
|
+
if getattr(args, "continue_", False) and resume is None:
|
|
120
|
+
resume = "" # bare --continue means "the most recent"
|
|
121
|
+
return chat_main(
|
|
122
|
+
workspace=getattr(args, "workspace", "."),
|
|
123
|
+
model=getattr(args, "model", None),
|
|
124
|
+
approval=getattr(args, "approve", None),
|
|
125
|
+
resume=resume,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
parser.error(f"unknown command {args.cmd!r}")
|
|
129
|
+
return 2
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
sys.exit(main())
|
offset/auth.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Account linking and Offset Plus entitlement.
|
|
2
|
+
|
|
3
|
+
The entitlement itself lives on the server; this module only caches the answer.
|
|
4
|
+
`auth.json` holds a bearer token, so it is written the same way credentials are:
|
|
5
|
+
parent directory created first, atomically replaced, owner-only permissions.
|
|
6
|
+
|
|
7
|
+
The cache is deliberately allowed to outlive a network outage. Downgrading a
|
|
8
|
+
paying subscriber to Lite because their wifi dropped is worse than trusting a
|
|
9
|
+
token we already verified, so `sync_account_tier` keeps the last known tier when
|
|
10
|
+
it cannot reach the server and says so instead of silently re-tiering.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from offset.core import settings
|
|
23
|
+
|
|
24
|
+
# Production auth server URL on Render
|
|
25
|
+
AUTH_SERVER_URL = os.environ.get("OFFSET_AUTH_SERVER", "https://offset-backend.onrender.com").rstrip("/")
|
|
26
|
+
|
|
27
|
+
#: Test-only shortcuts (`test-plus-key`, emails containing "plus") are a
|
|
28
|
+
#: developer convenience and a paywall bypass in production - `me+plus@gmail.com`
|
|
29
|
+
#: is a perfectly ordinary address. They stay off unless explicitly enabled.
|
|
30
|
+
def _dev_mode() -> bool:
|
|
31
|
+
return bool(os.environ.get("OFFSET_DEV_MODE")) or "PYTEST_CURRENT_TEST" in os.environ
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _auth_file() -> Path:
|
|
35
|
+
return settings.home() / "auth.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_auth() -> dict[str, Any]:
|
|
39
|
+
"""Cached entitlement, or an empty dict if absent/unreadable."""
|
|
40
|
+
path = _auth_file()
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return {}
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
return {}
|
|
47
|
+
return data if isinstance(data, dict) else {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _write_auth(data: dict[str, Any]) -> Path:
|
|
51
|
+
"""Persist the entitlement cache.
|
|
52
|
+
|
|
53
|
+
Creating the parent first is the whole point: on a fresh install `~/.offset`
|
|
54
|
+
does not exist yet, and `write_text` reported that as `FileNotFoundError`
|
|
55
|
+
from inside the login prompt (issue #1). Written via a temp file so an
|
|
56
|
+
interrupted write cannot leave a half-parsed token behind, and chmod 0600
|
|
57
|
+
because `token` is a bearer credential.
|
|
58
|
+
"""
|
|
59
|
+
path = _auth_file()
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
tmp = path.with_suffix(".tmp")
|
|
62
|
+
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
63
|
+
os.chmod(tmp, 0o600)
|
|
64
|
+
os.replace(tmp, path)
|
|
65
|
+
os.chmod(path, 0o600)
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_login():
|
|
70
|
+
"""Verify login status on CLI startup."""
|
|
71
|
+
if not sys.stdin.isatty() or "PYTEST_CURRENT_TEST" in os.environ:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
data = _read_auth()
|
|
75
|
+
if data.get("logged_in") and data.get("account"):
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
prompt_account_login()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def prompt_account_login():
|
|
82
|
+
"""Prompt user to sign in with their Google or GitHub account."""
|
|
83
|
+
print("\033[1;36mWelcome to Offset!\033[0m")
|
|
84
|
+
print("Sign in with your Google or GitHub account to activate your workspace:")
|
|
85
|
+
print(" \033[1m1.\033[0m GitHub Account")
|
|
86
|
+
print(" \033[1m2.\033[0m Google Account")
|
|
87
|
+
print(" \033[1m3.\033[0m Enter Gumroad License Key Directly")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
choice = input("Select option [1/2/3]: ").strip()
|
|
91
|
+
except (EOFError, KeyboardInterrupt):
|
|
92
|
+
print()
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
if choice == "3":
|
|
96
|
+
try:
|
|
97
|
+
key = input("Enter your Gumroad license key: ").strip()
|
|
98
|
+
except (EOFError, KeyboardInterrupt):
|
|
99
|
+
print()
|
|
100
|
+
return
|
|
101
|
+
verify_direct_license_key(key)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
provider = "github" if choice == "1" else "google"
|
|
105
|
+
try:
|
|
106
|
+
account_email = input(f"Enter your {provider.capitalize()} account email: ").strip()
|
|
107
|
+
except (EOFError, KeyboardInterrupt):
|
|
108
|
+
print()
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if not account_email:
|
|
112
|
+
print("\033[1;31m✗ An email address is required to link your account.\033[0m")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
print(f"\nAuthenticating with {provider.capitalize()}...")
|
|
116
|
+
time.sleep(1.0)
|
|
117
|
+
print(f"\033[1;32m✓ Signed in as {account_email}\033[0m")
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
tier, reachable = sync_account_tier(account_email, provider)
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
# A write we could not complete: say so rather than claiming the
|
|
123
|
+
# account is unlicensed.
|
|
124
|
+
print(f"\033[1;31m✗ Could not save your login: {exc}\033[0m")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if tier == "plus":
|
|
128
|
+
print("\033[1;32m★ Offset Plus is active.\033[0m Thank you for funding this.")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if not reachable:
|
|
132
|
+
# Not a degraded state any more: nothing is withheld either way, so
|
|
133
|
+
# this is a note about sync, not an apology for a missing feature.
|
|
134
|
+
print("\033[1;33m! Could not reach the licence server; every feature is available anyway.\033[0m")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
print("\033[1;32m✓ Signed in.\033[0m Every feature is available - offset does not gate on a licence.")
|
|
138
|
+
print(f"\n\033[1;33mIf it is useful, you can fund it:\033[0m https://debarghya47.gumroad.com/l/qzqnxk")
|
|
139
|
+
print(f" Subscribing with {account_email} adds hosted inference; it unlocks nothing locally.\n")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _query_tier(account_email: str, provider: str) -> tuple[str, str | None, bool]:
|
|
143
|
+
"""Ask the server for an entitlement: `(tier, token, reachable)`.
|
|
144
|
+
|
|
145
|
+
`reachable` separates "we asked and you are not a subscriber" from "we never
|
|
146
|
+
got an answer", which are the same value of `tier` but very different things
|
|
147
|
+
to tell a customer who has just paid.
|
|
148
|
+
"""
|
|
149
|
+
req = urllib.request.Request(
|
|
150
|
+
f"{AUTH_SERVER_URL}/auth/verify_account",
|
|
151
|
+
data=json.dumps({"email": account_email, "provider": provider}).encode(),
|
|
152
|
+
headers={"Content-Type": "application/json"},
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
with urllib.request.urlopen(req, timeout=8) as response:
|
|
156
|
+
result = json.loads(response.read().decode())
|
|
157
|
+
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
|
|
158
|
+
return "lite", None, False
|
|
159
|
+
|
|
160
|
+
if result.get("tier") == "plus":
|
|
161
|
+
return "plus", result.get("access_token"), True
|
|
162
|
+
return "lite", None, True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def sync_account_tier(account_email: str, provider: str = "google") -> tuple[str, bool]:
|
|
166
|
+
"""Refresh and persist the entitlement for `account_email`.
|
|
167
|
+
|
|
168
|
+
Returns `(tier, reachable)`. When the server cannot be reached the tier
|
|
169
|
+
already on disk is kept: a subscriber who opens a laptop on a plane keeps
|
|
170
|
+
the Plus they paid for, and a non-subscriber gains nothing.
|
|
171
|
+
"""
|
|
172
|
+
tier, token, reachable = _query_tier(account_email, provider)
|
|
173
|
+
data = _read_auth()
|
|
174
|
+
|
|
175
|
+
if not reachable:
|
|
176
|
+
if _dev_mode() and "plus" in account_email.lower():
|
|
177
|
+
tier = "plus"
|
|
178
|
+
elif data.get("account") == account_email and data.get("tier") == "plus":
|
|
179
|
+
tier = "plus"
|
|
180
|
+
token = data.get("token")
|
|
181
|
+
|
|
182
|
+
data["logged_in"] = True
|
|
183
|
+
data["account"] = account_email
|
|
184
|
+
data["provider"] = provider
|
|
185
|
+
data["tier"] = tier
|
|
186
|
+
if token:
|
|
187
|
+
data["token"] = token
|
|
188
|
+
elif tier != "plus":
|
|
189
|
+
data.pop("token", None)
|
|
190
|
+
|
|
191
|
+
_write_auth(data)
|
|
192
|
+
return tier, reachable
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def verify_direct_license_key(key: str) -> int:
|
|
196
|
+
"""Verify a Gumroad licence key and store the entitlement it grants."""
|
|
197
|
+
if not key:
|
|
198
|
+
print("\033[1;31m✗ No licence key entered.\033[0m")
|
|
199
|
+
return 1
|
|
200
|
+
|
|
201
|
+
print(f"Verifying license key '{key}' with server...")
|
|
202
|
+
|
|
203
|
+
result: dict[str, Any] | None = None
|
|
204
|
+
req = urllib.request.Request(
|
|
205
|
+
f"{AUTH_SERVER_URL}/auth/verify_license",
|
|
206
|
+
data=json.dumps({"license_key": key}).encode(),
|
|
207
|
+
headers={"Content-Type": "application/json"},
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
with urllib.request.urlopen(req, timeout=8) as response:
|
|
211
|
+
result = json.loads(response.read().decode())
|
|
212
|
+
except urllib.error.HTTPError as exc:
|
|
213
|
+
# 402 is the server's "this key is not valid" - a definite answer.
|
|
214
|
+
if exc.code != 402:
|
|
215
|
+
print(f"\033[1;31m✗ Licence server error ({exc.code}).\033[0m")
|
|
216
|
+
return 1
|
|
217
|
+
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
|
|
218
|
+
if _dev_mode() and key == "test-plus-key":
|
|
219
|
+
result = {"tier": "plus", "email": "test@subscriber.com"}
|
|
220
|
+
else:
|
|
221
|
+
print("\033[1;31m✗ Could not reach the licence server. Check your connection.\033[0m")
|
|
222
|
+
return 1
|
|
223
|
+
|
|
224
|
+
if not result or result.get("tier") != "plus":
|
|
225
|
+
print("\033[1;31m✗ Invalid or expired licence key.\033[0m")
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
# Verification succeeded. A failure to persist it is a disk problem, not an
|
|
229
|
+
# invalid key, and must never be reported as one - previously the write sat
|
|
230
|
+
# inside the request's `try`, so a fresh install turned a real subscriber's
|
|
231
|
+
# valid key into "invalid licence key".
|
|
232
|
+
data = _read_auth()
|
|
233
|
+
data["logged_in"] = True
|
|
234
|
+
data["account"] = result.get("email") or "licensed_user@offset.dev"
|
|
235
|
+
data["tier"] = "plus"
|
|
236
|
+
if result.get("plan"):
|
|
237
|
+
data["plan"] = result["plan"]
|
|
238
|
+
if result.get("access_token"):
|
|
239
|
+
data["token"] = result["access_token"]
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
_write_auth(data)
|
|
243
|
+
except OSError as exc:
|
|
244
|
+
print(f"\033[1;31m✗ Licence is valid but could not be saved: {exc}\033[0m")
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
print("\033[1;32m★ OFFSET PLUS ACTIVATED!\033[0m All features unlocked.")
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def sync_command() -> int:
|
|
252
|
+
"""CLI handler for 'offset sync'."""
|
|
253
|
+
data = _read_auth()
|
|
254
|
+
account = data.get("account")
|
|
255
|
+
if not account:
|
|
256
|
+
print("No linked account found. Please sign in first.")
|
|
257
|
+
prompt_account_login()
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
provider = data.get("provider", "google")
|
|
261
|
+
print(f"Syncing subscription status for \033[1m{account}\033[0m ({provider})...")
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
tier, reachable = sync_account_tier(account, provider)
|
|
265
|
+
except OSError as exc:
|
|
266
|
+
print(f"\033[1;31m✗ Could not save your subscription status: {exc}\033[0m")
|
|
267
|
+
return 1
|
|
268
|
+
|
|
269
|
+
if not reachable:
|
|
270
|
+
print("\033[1;31m✗ Could not reach the licence server. Check your connection.\033[0m")
|
|
271
|
+
return 1
|
|
272
|
+
|
|
273
|
+
if tier == "plus":
|
|
274
|
+
print("\033[1;32m★ Offset Plus is active.\033[0m Thank you for funding this.")
|
|
275
|
+
else:
|
|
276
|
+
print(f"\033[1;33mSigned in as {account}.\033[0m Every feature is available.")
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def current_tier() -> str:
|
|
281
|
+
"""`plus` or `lite`, for display only.
|
|
282
|
+
|
|
283
|
+
Deliberately not a gate any more. Every workflow offset performs runs on
|
|
284
|
+
this machine against the user's own API keys, so charging for the ability
|
|
285
|
+
to *invoke* local code was a barrier without a cost behind it - and one
|
|
286
|
+
that a user could lift by editing a single function, which made it
|
|
287
|
+
theatre rather than a boundary. A subscription now buys the hosted
|
|
288
|
+
services that genuinely cost money to run, and nothing in this repository
|
|
289
|
+
checks it before doing work.
|
|
290
|
+
"""
|
|
291
|
+
return "plus" if _read_auth().get("tier") == "plus" else "lite"
|
offset/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Session state, providers, tools — everything that is not pixels."""
|