systemr-cli 1.0.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.
- neo/__init__.py +3 -0
- neo/__main__.py +5 -0
- neo/auth.py +192 -0
- neo/cli.py +205 -0
- neo/client.py +64 -0
- neo/commands/__init__.py +0 -0
- neo/commands/auth_commands.py +303 -0
- neo/commands/chat_commands.py +937 -0
- neo/commands/cron_commands.py +179 -0
- neo/commands/doctor_command.py +178 -0
- neo/commands/eval_commands.py +73 -0
- neo/commands/journal_commands.py +197 -0
- neo/commands/plan_commands.py +77 -0
- neo/commands/risk_commands.py +68 -0
- neo/commands/scan_commands.py +62 -0
- neo/commands/size_commands.py +60 -0
- neo/config.py +70 -0
- neo/confirmation.py +311 -0
- neo/credits.py +98 -0
- neo/cron.py +365 -0
- neo/display/__init__.py +0 -0
- neo/display/chat_renderer.py +127 -0
- neo/display/formatters.py +112 -0
- neo/display/tables.py +53 -0
- neo/display/theme.py +154 -0
- neo/hooks.py +170 -0
- neo/logging.py +56 -0
- neo/model_failover.py +193 -0
- neo/orchestrator.py +288 -0
- neo/profile.py +505 -0
- neo/store.py +405 -0
- neo/streaming.py +315 -0
- neo/types.py +109 -0
- systemr_cli-1.0.0.dist-info/METADATA +191 -0
- systemr_cli-1.0.0.dist-info/RECORD +37 -0
- systemr_cli-1.0.0.dist-info/WHEEL +4 -0
- systemr_cli-1.0.0.dist-info/entry_points.txt +3 -0
neo/__init__.py
ADDED
neo/__main__.py
ADDED
neo/auth.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Authentication manager — login, logout, token storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from neo.config import AUTH_FILE, ensure_neo_home, get_api_url
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AuthToken:
|
|
17
|
+
access_token: str
|
|
18
|
+
refresh_token: str | None
|
|
19
|
+
email: str
|
|
20
|
+
expires_at: float # Unix timestamp
|
|
21
|
+
|
|
22
|
+
def is_expired(self) -> bool:
|
|
23
|
+
return time.time() >= self.expires_at
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
return {
|
|
27
|
+
"access_token": self.access_token,
|
|
28
|
+
"refresh_token": self.refresh_token,
|
|
29
|
+
"email": self.email,
|
|
30
|
+
"expires_at": self.expires_at,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, data: dict) -> AuthToken:
|
|
35
|
+
return cls(
|
|
36
|
+
access_token=data["access_token"],
|
|
37
|
+
refresh_token=data.get("refresh_token"),
|
|
38
|
+
email=data["email"],
|
|
39
|
+
expires_at=data.get("expires_at", 0),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthManager:
|
|
44
|
+
"""Handles login, logout, and token persistence."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._token: AuthToken | None = None
|
|
48
|
+
|
|
49
|
+
def load(self) -> AuthToken | None:
|
|
50
|
+
"""Load stored token from disk."""
|
|
51
|
+
if self._token is not None:
|
|
52
|
+
return self._token
|
|
53
|
+
if not AUTH_FILE.exists():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(AUTH_FILE.read_text())
|
|
57
|
+
self._token = AuthToken.from_dict(data)
|
|
58
|
+
return self._token
|
|
59
|
+
except (json.JSONDecodeError, KeyError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def save(self, token: AuthToken) -> None:
|
|
63
|
+
"""Persist token to disk with secure permissions."""
|
|
64
|
+
ensure_neo_home()
|
|
65
|
+
AUTH_FILE.write_text(json.dumps(token.to_dict(), indent=2))
|
|
66
|
+
os.chmod(AUTH_FILE, 0o600)
|
|
67
|
+
self._token = token
|
|
68
|
+
|
|
69
|
+
def clear(self) -> None:
|
|
70
|
+
"""Remove stored credentials and attempt server-side revocation.
|
|
71
|
+
|
|
72
|
+
Calls the logout endpoint to invalidate the token server-side.
|
|
73
|
+
The local file is always deleted regardless of revocation success.
|
|
74
|
+
"""
|
|
75
|
+
token = self.load()
|
|
76
|
+
if token and token.access_token:
|
|
77
|
+
self._try_revoke(token)
|
|
78
|
+
if AUTH_FILE.exists():
|
|
79
|
+
AUTH_FILE.unlink()
|
|
80
|
+
self._token = None
|
|
81
|
+
|
|
82
|
+
def _try_revoke(self, token: AuthToken) -> None:
|
|
83
|
+
"""Attempt to revoke the token server-side.
|
|
84
|
+
|
|
85
|
+
Best-effort — silent on failure. The local clear always happens.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
token: The token to revoke.
|
|
89
|
+
"""
|
|
90
|
+
api_url = get_api_url()
|
|
91
|
+
try:
|
|
92
|
+
httpx.post(
|
|
93
|
+
f"{api_url}/api/auth/logout",
|
|
94
|
+
headers={"Authorization": f"Bearer {token.access_token}"},
|
|
95
|
+
timeout=5.0,
|
|
96
|
+
)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def get_token(self) -> AuthToken | None:
|
|
101
|
+
"""Get current token, returning None if expired."""
|
|
102
|
+
token = self.load()
|
|
103
|
+
if token is None:
|
|
104
|
+
return None
|
|
105
|
+
if token.is_expired():
|
|
106
|
+
refreshed = self._try_refresh(token)
|
|
107
|
+
if refreshed:
|
|
108
|
+
self.save(refreshed)
|
|
109
|
+
return refreshed
|
|
110
|
+
self.clear()
|
|
111
|
+
return None
|
|
112
|
+
return token
|
|
113
|
+
|
|
114
|
+
async def register(self, email: str, password: str, name: str = "") -> AuthToken:
|
|
115
|
+
"""Register a new account and auto-login.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
email: User's email address.
|
|
119
|
+
password: Chosen password.
|
|
120
|
+
name: Optional display name.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
AuthToken for the new account.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
httpx.HTTPStatusError: If registration fails (e.g., email taken).
|
|
127
|
+
"""
|
|
128
|
+
api_url = get_api_url()
|
|
129
|
+
async with httpx.AsyncClient() as client:
|
|
130
|
+
resp = await client.post(
|
|
131
|
+
f"{api_url}/api/auth/register",
|
|
132
|
+
json={"email": email, "password": password, "name": name},
|
|
133
|
+
timeout=30.0,
|
|
134
|
+
)
|
|
135
|
+
resp.raise_for_status()
|
|
136
|
+
data = resp.json()
|
|
137
|
+
|
|
138
|
+
token = AuthToken(
|
|
139
|
+
access_token=data.get("access_token") or data.get("session_token", ""),
|
|
140
|
+
refresh_token=data.get("refresh_token"),
|
|
141
|
+
email=email,
|
|
142
|
+
expires_at=time.time() + data.get("expires_in", 86400),
|
|
143
|
+
)
|
|
144
|
+
self.save(token)
|
|
145
|
+
return token
|
|
146
|
+
|
|
147
|
+
async def login(self, email: str, password: str) -> AuthToken:
|
|
148
|
+
"""Authenticate against System R API and store the token."""
|
|
149
|
+
api_url = get_api_url()
|
|
150
|
+
async with httpx.AsyncClient() as client:
|
|
151
|
+
resp = await client.post(
|
|
152
|
+
f"{api_url}/api/auth/login",
|
|
153
|
+
json={"email": email, "password": password},
|
|
154
|
+
timeout=30.0,
|
|
155
|
+
)
|
|
156
|
+
resp.raise_for_status()
|
|
157
|
+
data = resp.json()
|
|
158
|
+
|
|
159
|
+
token = AuthToken(
|
|
160
|
+
access_token=data.get("access_token") or data.get("session_token", ""),
|
|
161
|
+
refresh_token=data.get("refresh_token"),
|
|
162
|
+
email=email,
|
|
163
|
+
expires_at=time.time() + data.get("expires_in", 86400),
|
|
164
|
+
)
|
|
165
|
+
self.save(token)
|
|
166
|
+
return token
|
|
167
|
+
|
|
168
|
+
def _try_refresh(self, token: AuthToken) -> AuthToken | None:
|
|
169
|
+
"""Synchronously attempt to refresh an expired token."""
|
|
170
|
+
if not token.refresh_token:
|
|
171
|
+
return None
|
|
172
|
+
api_url = get_api_url()
|
|
173
|
+
try:
|
|
174
|
+
resp = httpx.post(
|
|
175
|
+
f"{api_url}/api/auth/refresh",
|
|
176
|
+
json={"refresh_token": token.refresh_token},
|
|
177
|
+
timeout=15.0,
|
|
178
|
+
)
|
|
179
|
+
resp.raise_for_status()
|
|
180
|
+
data = resp.json()
|
|
181
|
+
return AuthToken(
|
|
182
|
+
access_token=data["access_token"],
|
|
183
|
+
refresh_token=data.get("refresh_token", token.refresh_token),
|
|
184
|
+
email=token.email,
|
|
185
|
+
expires_at=time.time() + data.get("expires_in", 3600),
|
|
186
|
+
)
|
|
187
|
+
except httpx.HTTPError:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def is_authenticated(self) -> bool:
|
|
192
|
+
return self.get_token() is not None
|
neo/cli.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""System R CLI — main entry point.
|
|
2
|
+
|
|
3
|
+
Shows the home screen when invoked without subcommands.
|
|
4
|
+
Initializes the local directory structure on first run.
|
|
5
|
+
Checks for updates on launch (non-blocking).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from neo import __version__
|
|
13
|
+
from neo.commands.auth_commands import balance, link_wallet, login, logout, pay, register, verify, whoami
|
|
14
|
+
from neo.commands.chat_commands import chat
|
|
15
|
+
from neo.commands.cron_commands import cron
|
|
16
|
+
from neo.commands.doctor_command import doctor
|
|
17
|
+
from neo.commands.eval_commands import eval
|
|
18
|
+
from neo.commands.journal_commands import journal
|
|
19
|
+
from neo.commands.plan_commands import plan
|
|
20
|
+
from neo.commands.risk_commands import risk
|
|
21
|
+
from neo.commands.scan_commands import scan
|
|
22
|
+
from neo.commands.size_commands import size
|
|
23
|
+
from neo.display.theme import (
|
|
24
|
+
WHITE,
|
|
25
|
+
GRAY,
|
|
26
|
+
DIM,
|
|
27
|
+
MUTED,
|
|
28
|
+
GREEN,
|
|
29
|
+
RED,
|
|
30
|
+
ICON_CONNECTED,
|
|
31
|
+
ICON_DISCONNECTED,
|
|
32
|
+
console,
|
|
33
|
+
print_banner,
|
|
34
|
+
print_separator,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group(invoke_without_command=True)
|
|
39
|
+
@click.version_option(version=__version__, prog_name="systemr")
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def cli(ctx: click.Context) -> None:
|
|
42
|
+
"""System R — trading operating system in your terminal."""
|
|
43
|
+
# Configure logging (suppress INFO from terminal, only WARN+ to stderr)
|
|
44
|
+
from neo.logging import configure_logging
|
|
45
|
+
configure_logging(debug=False)
|
|
46
|
+
|
|
47
|
+
# Initialize local directory on first run
|
|
48
|
+
from neo.profile import init_all
|
|
49
|
+
init_all()
|
|
50
|
+
|
|
51
|
+
# Load saved permission profile
|
|
52
|
+
from neo.confirmation import load_profile_from_config
|
|
53
|
+
load_profile_from_config()
|
|
54
|
+
|
|
55
|
+
if ctx.invoked_subcommand is None:
|
|
56
|
+
_show_home()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _show_home() -> None:
|
|
60
|
+
"""Home screen — clean, three-state status display."""
|
|
61
|
+
from neo.auth import AuthManager
|
|
62
|
+
from neo.profile import profile_exists
|
|
63
|
+
|
|
64
|
+
print_banner()
|
|
65
|
+
|
|
66
|
+
auth = AuthManager()
|
|
67
|
+
token = auth.get_token()
|
|
68
|
+
|
|
69
|
+
if not token:
|
|
70
|
+
# State 1: Not logged in
|
|
71
|
+
console.print(
|
|
72
|
+
f" [{RED}]{ICON_DISCONNECTED}[/] [{RED}]not connected[/] "
|
|
73
|
+
f"[{MUTED}]run[/] [{DIM}]systemr login[/]"
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
# State 2 or 3: Logged in
|
|
77
|
+
console.print(
|
|
78
|
+
f" [{GREEN}]{ICON_CONNECTED}[/] [{GREEN}]connected[/] [{GRAY}]as {token.email}[/]"
|
|
79
|
+
)
|
|
80
|
+
if not profile_exists():
|
|
81
|
+
# State 2: No profile yet
|
|
82
|
+
console.print(
|
|
83
|
+
f" [{GRAY}]{ICON_DISCONNECTED}[/] [{GRAY}]no profile yet[/] "
|
|
84
|
+
f"[{MUTED}]run[/] [{DIM}]systemr setup[/]"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
console.print()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Command registration ────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
# Auth & Billing
|
|
93
|
+
cli.add_command(register)
|
|
94
|
+
cli.add_command(login)
|
|
95
|
+
cli.add_command(verify)
|
|
96
|
+
cli.add_command(pay)
|
|
97
|
+
cli.add_command(balance)
|
|
98
|
+
cli.add_command(link_wallet)
|
|
99
|
+
cli.add_command(logout)
|
|
100
|
+
cli.add_command(whoami)
|
|
101
|
+
|
|
102
|
+
# Trading commands
|
|
103
|
+
cli.add_command(size)
|
|
104
|
+
cli.add_command(risk)
|
|
105
|
+
cli.add_command(eval)
|
|
106
|
+
cli.add_command(scan)
|
|
107
|
+
cli.add_command(plan)
|
|
108
|
+
|
|
109
|
+
# Local
|
|
110
|
+
cli.add_command(journal)
|
|
111
|
+
|
|
112
|
+
# Interactive
|
|
113
|
+
cli.add_command(chat)
|
|
114
|
+
|
|
115
|
+
# Automation
|
|
116
|
+
cli.add_command(cron)
|
|
117
|
+
|
|
118
|
+
# Diagnostics
|
|
119
|
+
cli.add_command(doctor)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Setup
|
|
123
|
+
@click.command()
|
|
124
|
+
def setup() -> None:
|
|
125
|
+
"""Run the profile setup wizard."""
|
|
126
|
+
from neo.display.theme import print_success, print_info
|
|
127
|
+
from neo.profile import (
|
|
128
|
+
profile_exists, save_profile, save_rules, save_standing_order, init_all,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
init_all()
|
|
132
|
+
print_banner()
|
|
133
|
+
|
|
134
|
+
if profile_exists():
|
|
135
|
+
print_info("Profile already exists. Answers will overwrite it.")
|
|
136
|
+
|
|
137
|
+
console.print(f" [{WHITE}]Welcome. Let's set up your profile.[/]")
|
|
138
|
+
console.print()
|
|
139
|
+
|
|
140
|
+
style = click.prompt(click.style(" Trading style", fg="white"))
|
|
141
|
+
timeframe = click.prompt(click.style(" Timeframe", fg="white"))
|
|
142
|
+
risk_pct = click.prompt(click.style(" Max risk per trade (%)", fg="white"))
|
|
143
|
+
daily_loss = click.prompt(click.style(" Max daily loss (%)", fg="white"))
|
|
144
|
+
max_pos = click.prompt(click.style(" Max open positions", fg="white"), type=int)
|
|
145
|
+
broker = click.prompt(click.style(" Primary broker", fg="white"))
|
|
146
|
+
name = click.prompt(click.style(" Your name", fg="white"))
|
|
147
|
+
|
|
148
|
+
console.print()
|
|
149
|
+
console.print(f" [{GRAY}]Any hard rules? (comma-separated, or Enter to skip)[/]")
|
|
150
|
+
rules_input = click.prompt(
|
|
151
|
+
click.style(" Rules", fg="white"), default="", show_default=False,
|
|
152
|
+
)
|
|
153
|
+
hard_rules = [r.strip() for r in rules_input.split(",") if r.strip()]
|
|
154
|
+
|
|
155
|
+
experience = click.prompt(
|
|
156
|
+
click.style(" Experience level", fg="white"),
|
|
157
|
+
type=click.Choice(["beginner", "intermediate", "advanced"]),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
console.print()
|
|
161
|
+
print_separator()
|
|
162
|
+
console.print()
|
|
163
|
+
|
|
164
|
+
save_profile(
|
|
165
|
+
name=name, style=style, timeframe=timeframe,
|
|
166
|
+
experience=experience, risk_pct=risk_pct,
|
|
167
|
+
daily_loss_pct=daily_loss, max_positions=max_pos, broker=broker,
|
|
168
|
+
)
|
|
169
|
+
print_success("Profile saved to ~/.systemr/PROFILE.md")
|
|
170
|
+
|
|
171
|
+
if hard_rules:
|
|
172
|
+
save_rules(hard_rules=hard_rules, soft_rules=[])
|
|
173
|
+
print_success("Rules saved to ~/.systemr/RULES.md")
|
|
174
|
+
|
|
175
|
+
# Standing orders (optional)
|
|
176
|
+
console.print()
|
|
177
|
+
console.print(f" [{GRAY}]Standing orders give your agent authority to act within boundaries.[/]")
|
|
178
|
+
console.print(f" [{DIM}]Example: 'Every morning, scan watchlist for setups. Report only, no trades.'[/]")
|
|
179
|
+
add_orders = click.confirm(
|
|
180
|
+
click.style(" Add a standing order?", fg="white"), default=False,
|
|
181
|
+
)
|
|
182
|
+
if add_orders:
|
|
183
|
+
order_name = click.prompt(click.style(" Order name", fg="white"))
|
|
184
|
+
order_action = click.prompt(click.style(" What should it do?", fg="white"))
|
|
185
|
+
order_trigger = click.prompt(
|
|
186
|
+
click.style(" When? (e.g., 'Daily at 7AM', 'On trade close')", fg="white"),
|
|
187
|
+
)
|
|
188
|
+
save_standing_order(
|
|
189
|
+
name=order_name,
|
|
190
|
+
scope=order_action,
|
|
191
|
+
trigger=order_trigger,
|
|
192
|
+
approval="Report only — do NOT execute trades without confirmation",
|
|
193
|
+
escalation="Alert if unable to complete",
|
|
194
|
+
action=order_action,
|
|
195
|
+
)
|
|
196
|
+
print_success(f"Standing order '{order_name}' saved to RULES.md")
|
|
197
|
+
|
|
198
|
+
console.print()
|
|
199
|
+
console.print(
|
|
200
|
+
f" [{WHITE}]You're ready. Run `systemr chat` to start.[/]"
|
|
201
|
+
)
|
|
202
|
+
console.print()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
cli.add_command(setup)
|
neo/client.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""NeoClient — async HTTP client with session auth and auto-refresh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from neo.auth import AuthManager
|
|
10
|
+
from neo.config import get_api_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NeoClient:
|
|
14
|
+
"""Authenticated async HTTP client for System R API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, auth: AuthManager) -> None:
|
|
17
|
+
self._auth = auth
|
|
18
|
+
self._base_url = get_api_url()
|
|
19
|
+
|
|
20
|
+
async def request(
|
|
21
|
+
self,
|
|
22
|
+
method: str,
|
|
23
|
+
path: str,
|
|
24
|
+
*,
|
|
25
|
+
json: dict | None = None,
|
|
26
|
+
params: dict | None = None,
|
|
27
|
+
timeout: float = 30.0,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Make an authenticated API request. Retries once on 401."""
|
|
30
|
+
headers = self._auth_headers()
|
|
31
|
+
async with httpx.AsyncClient(base_url=self._base_url) as client:
|
|
32
|
+
resp = await client.request(
|
|
33
|
+
method, path, json=json, params=params, headers=headers, timeout=timeout,
|
|
34
|
+
)
|
|
35
|
+
# Auto-refresh on 401
|
|
36
|
+
if resp.status_code == 401:
|
|
37
|
+
token = self._auth.get_token()
|
|
38
|
+
if token is None:
|
|
39
|
+
raise AuthRequired()
|
|
40
|
+
headers = self._auth_headers()
|
|
41
|
+
resp = await client.request(
|
|
42
|
+
method, path, json=json, params=params, headers=headers, timeout=timeout,
|
|
43
|
+
)
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
return resp.json()
|
|
46
|
+
|
|
47
|
+
async def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
48
|
+
return await self.request("GET", path, **kwargs)
|
|
49
|
+
|
|
50
|
+
async def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
51
|
+
return await self.request("POST", path, **kwargs)
|
|
52
|
+
|
|
53
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
54
|
+
token = self._auth.get_token()
|
|
55
|
+
if token is None:
|
|
56
|
+
raise AuthRequired()
|
|
57
|
+
return {"Authorization": f"Bearer {token.access_token}"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthRequired(Exception):
|
|
61
|
+
"""Raised when a request requires auth but no valid token exists."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
super().__init__("Not authenticated. Run `systemr login` first.")
|
neo/commands/__init__.py
ADDED
|
File without changes
|