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 ADDED
@@ -0,0 +1,3 @@
1
+ """System R CLI — trading operating system in your terminal."""
2
+
3
+ __version__ = "1.0.0"
neo/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m neo`."""
2
+
3
+ from neo.cli import cli
4
+
5
+ cli()
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.")
File without changes