nxplora 0.16.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.
- nxplora-0.16.0.dist-info/METADATA +56 -0
- nxplora-0.16.0.dist-info/RECORD +11 -0
- nxplora-0.16.0.dist-info/WHEEL +4 -0
- nxplora-0.16.0.dist-info/entry_points.txt +3 -0
- nxplora_cli/__init__.py +1 -0
- nxplora_cli/__main__.py +44 -0
- nxplora_cli/app.py +422 -0
- nxplora_cli/auth.py +151 -0
- nxplora_cli/events.py +75 -0
- nxplora_cli/opening.py +194 -0
- nxplora_cli/transport.py +221 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nxplora
|
|
3
|
+
Version: 0.16.0
|
|
4
|
+
Summary: NX in your terminal, on your Nexplora account.
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: certifi
|
|
8
|
+
Requires-Dist: textual<2,>=1.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# nx-cli
|
|
12
|
+
|
|
13
|
+
**Private source. Proprietary. Published to PyPI as `nxplora` only when ready.**
|
|
14
|
+
|
|
15
|
+
The NX terminal client: a thin client of a person's Nexplora account. Each message is a real run on the same engine the
|
|
16
|
+
chat uses, with the account's approvals and billing. It shows what really came back and nothing else.
|
|
17
|
+
|
|
18
|
+
## Charter
|
|
19
|
+
|
|
20
|
+
| nx-cli OWNS | nx-cli NEVER contains |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Sign-in / enrollment with nx-cloud | Brain logic: routing, prompts, CodeChat harness, council, cognition router, brain core (these stay in private `Nexploraai/NX`) |
|
|
23
|
+
| Terminal UI, streaming, rendering | Model IDs, provider URLs or provider keys |
|
|
24
|
+
| Actions that must run on the operator's own machine (host, computer, phone) — only under nx-cloud-issued, approval-gated commands | Global Nexplora / NX secrets |
|
|
25
|
+
| Its own update channel | Any upload / publish path to a package index other than the gated release job |
|
|
26
|
+
|
|
27
|
+
Every published build must pass a check that none of the private NX modules are present in the wheel.
|
|
28
|
+
|
|
29
|
+
## What it does
|
|
30
|
+
|
|
31
|
+
- **In a Nexplora Code session's terminal** it is already signed in: the app writes a short-lived session ticket (bound to the
|
|
32
|
+
person, the Operation and that session, one hour, usable only for sending a message there) to a private file, and renews it
|
|
33
|
+
while the page holds the terminal. The person's login is never on that computer. The ticket can't approve anything.
|
|
34
|
+
- **On any other computer** `nx` shows a short link and a code. Approve it in the browser and it's done. The sign-in stays in
|
|
35
|
+
`~/.nx-cli/credentials.json` (0600). `nx logout` ends it. Work runs on the person's Nexplora cloud computer, not this one.
|
|
36
|
+
- **Approvals** are given in the chat. `nx` says what is waiting and where, then follows the run as it goes on.
|
|
37
|
+
- **Colours** are the terminal's own, with no painted background, so it matches a light or dark panel.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python -m pip install .
|
|
41
|
+
nx
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Checks
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python -m pytest # includes a real local HTTP server (tests/stub_server.py)
|
|
48
|
+
python -m build --wheel && python scripts/check_wheel.py dist/*.whl # the charter's check; a release must pass it
|
|
49
|
+
python scripts/pty_proof.py "$(command -v nx)" /tmp/nx-proof # real pty, reads the screen, fails on any painted background
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## PyPI
|
|
53
|
+
|
|
54
|
+
The `nxplora` project name is owned and all historical releases are yanked (2026-09-29). Existing installs
|
|
55
|
+
run `nx upgrade` → `pip install -U nxplora`, so the first nx-cli release published under `nxplora` becomes
|
|
56
|
+
the migration path for those users. Never delete the PyPI project (name-takeover risk).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
nxplora_cli/__init__.py,sha256=3Msc5baw88UJubVj5AVFB8tExkT2OFIsNWe2leaoHhc,23
|
|
2
|
+
nxplora_cli/__main__.py,sha256=IZXKBzDjyPsmZMJx7kOlZWGlUPsup6PU3UB62qEPPy8,1656
|
|
3
|
+
nxplora_cli/app.py,sha256=1CjjZYM0lPTDrATtEkV_kjc3GCNE0RcYno7LBvPZrMg,19834
|
|
4
|
+
nxplora_cli/auth.py,sha256=cDH8W5YRT1-v5WPz_Cf-2tpTHaV21GWeYEEiM5HNS7U,5118
|
|
5
|
+
nxplora_cli/events.py,sha256=X10BXDHNkc80UC_5MxIFIkN_tjUXHxurZkEFJdafHy8,3447
|
|
6
|
+
nxplora_cli/opening.py,sha256=MU_UUQV3Gr74O_RK86TZcXcdFHMY-EBsEZcCTiwF1OQ,8426
|
|
7
|
+
nxplora_cli/transport.py,sha256=NlDCUJShVDPIMfAiseNBo3zTn79xJw9nDBjJJDAp0x0,9552
|
|
8
|
+
nxplora-0.16.0.dist-info/METADATA,sha256=CjmxfmQb7TCi7Ft9WiTtXzPCPY6AlVNV9hYb_gAM2L0,2841
|
|
9
|
+
nxplora-0.16.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
nxplora-0.16.0.dist-info/entry_points.txt,sha256=SlKpaKzRQ9mGKAylpk5N5zY03oHQC08luALiFGdtmfw,85
|
|
11
|
+
nxplora-0.16.0.dist-info/RECORD,,
|
nxplora_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.16.0"
|
nxplora_cli/__main__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .auth import DEFAULT_ORIGIN, forget_device_login, load_device_login, safe_origin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(prog="nx", description="NX in your terminal, on your Nexplora account")
|
|
12
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
13
|
+
parser.add_argument("--connect", metavar="URL", help="the Nexplora address to use (default: nexplora.ai)")
|
|
14
|
+
parser.add_argument("command", nargs="?", choices=["login", "logout"], help="sign in or out on this computer")
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
origin = safe_origin(args.connect) if args.connect else None
|
|
17
|
+
if args.connect and not origin:
|
|
18
|
+
parser.error("that address isn't https, so nx won't send your sign-in to it")
|
|
19
|
+
if args.command == "logout":
|
|
20
|
+
_logout()
|
|
21
|
+
return
|
|
22
|
+
from .app import NxploraApp
|
|
23
|
+
|
|
24
|
+
app = NxploraApp(origin=origin or DEFAULT_ORIGIN, origin_chosen=bool(origin), force_login=args.command == "login")
|
|
25
|
+
app.run(mouse=False)
|
|
26
|
+
if app.exit_note:
|
|
27
|
+
print(app.exit_note)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _logout() -> None:
|
|
31
|
+
from .transport import Unreachable, revoke_device_session
|
|
32
|
+
|
|
33
|
+
login = load_device_login()
|
|
34
|
+
if login and login.device_session_id:
|
|
35
|
+
try:
|
|
36
|
+
revoke_device_session(login.origin, login.access_token, login.device_session_id)
|
|
37
|
+
except Unreachable:
|
|
38
|
+
print("Signed out here, but Nexplora couldn't be reached. You can end this sign-in in your account.", file=sys.stderr)
|
|
39
|
+
forget_device_login()
|
|
40
|
+
print("Signed out on this computer.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
nxplora_cli/app.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""The terminal. A thin client: each message is a real run on the person's account, and all it shows is what really came back.
|
|
2
|
+
|
|
3
|
+
Colours: everything is the terminal's own (ansi_default and the 16 named colours), so nothing paints a box. A panel's theme
|
|
4
|
+
decides what they look like, light or dark. The old screen painted #edf1eb; on the terminal panel's 16-colour TERM that
|
|
5
|
+
became ANSI 15, which the panel's light theme sets to its dark ink, and that was the dark box.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import platform
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual.app import App, ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Vertical, VerticalScroll
|
|
18
|
+
from textual.widgets import Input, Static
|
|
19
|
+
|
|
20
|
+
from . import __version__, transport
|
|
21
|
+
from .auth import (
|
|
22
|
+
DeviceLogin,
|
|
23
|
+
SessionTicket,
|
|
24
|
+
forget_device_login,
|
|
25
|
+
load_device_login,
|
|
26
|
+
on_nexplora_computer,
|
|
27
|
+
read_session_ticket,
|
|
28
|
+
safe_origin,
|
|
29
|
+
save_device_login,
|
|
30
|
+
)
|
|
31
|
+
from .events import Line, clean, line_for
|
|
32
|
+
from .opening import WorkspaceContext, default_opening, inspect_workspace
|
|
33
|
+
|
|
34
|
+
APPROVE_BESIDE = "Approve or deny it in the chat beside this terminal."
|
|
35
|
+
TICKET_OUT = "This terminal's sign-in has run out. Close the terminal panel and open it again."
|
|
36
|
+
TICKET_MISSING = "This terminal isn't signed in. Close the terminal panel and open it again."
|
|
37
|
+
UNREACHABLE = "Can't reach Nexplora. Check the connection and try again."
|
|
38
|
+
HELP = "Type a message and press Enter. Esc stops the run. Page Up and Page Down scroll. /clear clears this screen. /quit leaves."
|
|
39
|
+
_TONE_STYLE = {"assistant": "", "step": "bright_black", "waiting": "bold yellow", "problem": "red", "you": "bold"}
|
|
40
|
+
_EVENT_POLL_S = 2.0
|
|
41
|
+
_TICKET_POLL_S = 1.0
|
|
42
|
+
_TICKET_WAIT_S = 8.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NxploraApp(App[None]):
|
|
46
|
+
TITLE = "nx"
|
|
47
|
+
ENABLE_COMMAND_PALETTE = False
|
|
48
|
+
CSS = """
|
|
49
|
+
Screen { background: ansi_default; color: ansi_default; layers: base; }
|
|
50
|
+
#brand { height: 1; margin: 1 2 0 2; background: ansi_default; color: ansi_bright_black; }
|
|
51
|
+
#signin { height: 1fr; margin: 2 2 0 2; background: ansi_default; color: ansi_default; }
|
|
52
|
+
#signin Static { height: auto; margin-bottom: 1; background: ansi_default; color: ansi_default; }
|
|
53
|
+
#transcript { height: 1fr; margin: 1 2 0 2; background: ansi_default; color: ansi_default; scrollbar-size: 0 0; }
|
|
54
|
+
.line { height: auto; margin-bottom: 1; background: ansi_default; color: ansi_default; }
|
|
55
|
+
#status { height: 1; margin: 0 2; background: ansi_default; color: ansi_bright_black; }
|
|
56
|
+
#prompt { height: 1; border: none; margin: 0 2 1 2; padding: 0; background: ansi_default; color: ansi_default; }
|
|
57
|
+
#prompt:focus { border: none; background: ansi_default; }
|
|
58
|
+
Input > .input--cursor { background: ansi_default; color: ansi_default; text-style: reverse; }
|
|
59
|
+
Input > .input--placeholder { background: ansi_default; color: ansi_bright_black; }
|
|
60
|
+
Input > .input--suggestion { background: ansi_default; color: ansi_bright_black; }
|
|
61
|
+
Input > .input--selection { background: ansi_default; color: ansi_default; text-style: reverse; }
|
|
62
|
+
"""
|
|
63
|
+
BINDINGS = [
|
|
64
|
+
Binding("escape", "stop_run", "Stop", priority=True),
|
|
65
|
+
Binding("ctrl+c", "quit", "Quit", priority=True),
|
|
66
|
+
Binding("pageup", "page(-1)", show=False, priority=True),
|
|
67
|
+
Binding("pagedown", "page(1)", show=False, priority=True),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
origin: str = "https://nexplora.ai",
|
|
73
|
+
origin_chosen: bool = False,
|
|
74
|
+
force_login: bool = False,
|
|
75
|
+
workspace: str | None = None,
|
|
76
|
+
opening_provider: Callable[[WorkspaceContext], str] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
super().__init__(ansi_color=True)
|
|
79
|
+
self.theme = "textual-ansi"
|
|
80
|
+
self.origin = origin
|
|
81
|
+
self.origin_chosen = origin_chosen
|
|
82
|
+
self.force_login = force_login
|
|
83
|
+
self.workspace = inspect_workspace(workspace)
|
|
84
|
+
self.opening_provider = opening_provider
|
|
85
|
+
self.exit_note = ""
|
|
86
|
+
self.ticket: SessionTicket | None = None
|
|
87
|
+
self.login: DeviceLogin | None = None
|
|
88
|
+
self.running = False
|
|
89
|
+
self.waiting_for_ok = False
|
|
90
|
+
self._stop_following = threading.Event()
|
|
91
|
+
self._quitting = threading.Event()
|
|
92
|
+
self._last_assistant: Static | None = None
|
|
93
|
+
self._last_assistant_text = ""
|
|
94
|
+
|
|
95
|
+
# ── never show a credential: Textual's crash report prints local variables, so it isn't used ──
|
|
96
|
+
def _fatal_error(self) -> None:
|
|
97
|
+
self.exit_note = "NX CLI hit a problem and closed. Run nx to start again."
|
|
98
|
+
self._close_messages_no_wait()
|
|
99
|
+
|
|
100
|
+
# ── screens ────────────────────────────────────────────────────────────────
|
|
101
|
+
def compose(self) -> ComposeResult:
|
|
102
|
+
yield Static("nx · Nexplora", id="brand")
|
|
103
|
+
yield Vertical(id="signin")
|
|
104
|
+
|
|
105
|
+
async def on_mount(self) -> None:
|
|
106
|
+
ticket = read_session_ticket()
|
|
107
|
+
if on_nexplora_computer() or ticket:
|
|
108
|
+
await self._say_signin("Signing in…")
|
|
109
|
+
self.run_worker(self._wait_for_ticket, thread=True, exclusive=True, group="signin")
|
|
110
|
+
return
|
|
111
|
+
login = None if self.force_login else load_device_login(self.origin if self.origin_chosen else None)
|
|
112
|
+
if login:
|
|
113
|
+
self.login = login
|
|
114
|
+
await self._open_work()
|
|
115
|
+
return
|
|
116
|
+
await self._say_signin("Getting a sign-in link…")
|
|
117
|
+
self.run_worker(self._device_grant, thread=True, exclusive=True, group="signin")
|
|
118
|
+
|
|
119
|
+
async def _say_signin(self, *lines: str) -> None:
|
|
120
|
+
box = self.query_one("#signin")
|
|
121
|
+
await box.remove_children()
|
|
122
|
+
for line in lines:
|
|
123
|
+
await box.mount(Static(Text(clean(line))))
|
|
124
|
+
|
|
125
|
+
async def _open_work(self) -> None:
|
|
126
|
+
await self.query_one("#signin").remove()
|
|
127
|
+
transcript = VerticalScroll(id="transcript", can_focus=False)
|
|
128
|
+
await self.mount(transcript)
|
|
129
|
+
await self.mount(Static("", id="status"))
|
|
130
|
+
await self.mount(Input(placeholder="Message NX", id="prompt"))
|
|
131
|
+
opening = self.opening_provider(self.workspace) if self.opening_provider else default_opening(self.workspace)
|
|
132
|
+
self._write(Line(opening, "assistant"))
|
|
133
|
+
if self.login:
|
|
134
|
+
self._write(Line("This runs on your Nexplora cloud computer, not on this one, and it doesn't see files here.", "step"))
|
|
135
|
+
self._last_assistant = None
|
|
136
|
+
self.query_one("#prompt", Input).focus()
|
|
137
|
+
|
|
138
|
+
# ── signing in ─────────────────────────────────────────────────────────────
|
|
139
|
+
def _wait_for_ticket(self) -> None:
|
|
140
|
+
"""On a Code session's computer the app puts the ticket here. Nothing to do but see it arrive."""
|
|
141
|
+
said = False
|
|
142
|
+
deadline = time.time() + _TICKET_WAIT_S
|
|
143
|
+
while not self._quitting.is_set():
|
|
144
|
+
ticket = read_session_ticket()
|
|
145
|
+
if ticket and ticket.fresh():
|
|
146
|
+
self.ticket = ticket
|
|
147
|
+
self.call_from_thread(self._open_work)
|
|
148
|
+
return
|
|
149
|
+
if not said and time.time() > deadline:
|
|
150
|
+
said = True
|
|
151
|
+
self.call_from_thread(self._say_signin, TICKET_OUT if ticket else TICKET_MISSING)
|
|
152
|
+
time.sleep(_TICKET_POLL_S)
|
|
153
|
+
|
|
154
|
+
def _device_grant(self) -> None:
|
|
155
|
+
while not self._quitting.is_set():
|
|
156
|
+
try:
|
|
157
|
+
grant = transport.start_device_grant(self.origin, __version__, platform.node()[:128])
|
|
158
|
+
except transport.Unreachable:
|
|
159
|
+
self._signin_line(UNREACHABLE)
|
|
160
|
+
if self._quitting.wait(5):
|
|
161
|
+
return
|
|
162
|
+
continue
|
|
163
|
+
except transport.Refused as refused:
|
|
164
|
+
self._signin_line(str(refused))
|
|
165
|
+
if self._quitting.wait(10):
|
|
166
|
+
return
|
|
167
|
+
continue
|
|
168
|
+
self._signin_line("Sign in to Nexplora", f"Open this link: {grant.link}", f"Your code is {grant.user_code}", "Waiting for you to approve it there…")
|
|
169
|
+
outcome = self._await_grant(grant)
|
|
170
|
+
if outcome == "signed_in" or outcome == "quit":
|
|
171
|
+
return
|
|
172
|
+
if outcome == "denied":
|
|
173
|
+
self._signin_line("That sign-in was denied. Run nx to try again, or Ctrl+C to leave.")
|
|
174
|
+
return
|
|
175
|
+
# expired: a fresh code, without asking
|
|
176
|
+
|
|
177
|
+
def _await_grant(self, grant: transport.DeviceCode) -> str:
|
|
178
|
+
deadline = time.time() + grant.expires_in_s
|
|
179
|
+
while time.time() < deadline:
|
|
180
|
+
if self._quitting.wait(max(1.0, grant.interval_s)):
|
|
181
|
+
return "quit"
|
|
182
|
+
try:
|
|
183
|
+
result = transport.poll_device_grant(self.origin, grant.device_code)
|
|
184
|
+
except transport.Unreachable:
|
|
185
|
+
continue
|
|
186
|
+
if isinstance(result, dict):
|
|
187
|
+
device_session = result.get("device_session_id")
|
|
188
|
+
self.login = DeviceLogin(
|
|
189
|
+
self.origin, str(result["access_token"]), str(result.get("refresh_token") or ""),
|
|
190
|
+
device_session if isinstance(device_session, str) else None,
|
|
191
|
+
)
|
|
192
|
+
save_device_login(self.login)
|
|
193
|
+
self.call_from_thread(self._open_work)
|
|
194
|
+
return "signed_in"
|
|
195
|
+
if result in ("denied", "expired"):
|
|
196
|
+
return str(result)
|
|
197
|
+
return "expired"
|
|
198
|
+
|
|
199
|
+
def _signin_line(self, *lines: str) -> None:
|
|
200
|
+
self.call_from_thread(self._say_signin, *lines)
|
|
201
|
+
|
|
202
|
+
# ── a credential, fresh, for one request ───────────────────────────────────
|
|
203
|
+
def _credential(self) -> tuple[str, str, str]:
|
|
204
|
+
"""(origin, bearer, session id). Raises Refused with the one plain line when there is none."""
|
|
205
|
+
if self.login:
|
|
206
|
+
login = self.login
|
|
207
|
+
if not login.session_id:
|
|
208
|
+
login.session_id = self._with_refresh(lambda bearer: transport.create_session(login.origin, bearer))
|
|
209
|
+
save_device_login(login)
|
|
210
|
+
return login.origin, login.access_token, login.session_id
|
|
211
|
+
ticket = read_session_ticket()
|
|
212
|
+
if not ticket:
|
|
213
|
+
raise transport.Refused(TICKET_MISSING)
|
|
214
|
+
if not ticket.fresh():
|
|
215
|
+
raise transport.Refused(TICKET_OUT)
|
|
216
|
+
return ticket.origin, ticket.ticket, ticket.session_id
|
|
217
|
+
|
|
218
|
+
def _with_refresh(self, call: Callable[[str], Any]) -> Any:
|
|
219
|
+
login = self.login
|
|
220
|
+
assert login is not None
|
|
221
|
+
try:
|
|
222
|
+
return call(login.access_token)
|
|
223
|
+
except transport.SignedOut:
|
|
224
|
+
tokens = transport.refresh_login(login.origin, login.refresh_token) if login.refresh_token else None
|
|
225
|
+
if not tokens:
|
|
226
|
+
raise
|
|
227
|
+
login.access_token, login.refresh_token = tokens
|
|
228
|
+
save_device_login(login)
|
|
229
|
+
return call(login.access_token)
|
|
230
|
+
|
|
231
|
+
def _signed_out_line(self) -> str:
|
|
232
|
+
if self.login:
|
|
233
|
+
forget_device_login()
|
|
234
|
+
return "You're signed out. Run nx to sign in again."
|
|
235
|
+
return TICKET_OUT
|
|
236
|
+
|
|
237
|
+
# ── a message ──────────────────────────────────────────────────────────────
|
|
238
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
239
|
+
text = event.value.strip()
|
|
240
|
+
event.input.value = ""
|
|
241
|
+
if not text:
|
|
242
|
+
return
|
|
243
|
+
if text == "/quit":
|
|
244
|
+
self.exit()
|
|
245
|
+
return
|
|
246
|
+
if text == "/clear":
|
|
247
|
+
await self.query_one("#transcript").remove_children()
|
|
248
|
+
self._last_assistant = None
|
|
249
|
+
return
|
|
250
|
+
if text == "/help":
|
|
251
|
+
self._write(Line(HELP, "step"))
|
|
252
|
+
return
|
|
253
|
+
if self.running:
|
|
254
|
+
self._write(Line("NX is still on the last message. Wait for it, or press Esc to stop it.", "step"))
|
|
255
|
+
return
|
|
256
|
+
self._write(Line(f"› {text}", "you"))
|
|
257
|
+
self._last_assistant = None
|
|
258
|
+
self.running = True
|
|
259
|
+
self._stop_following.clear()
|
|
260
|
+
self._status("Working…")
|
|
261
|
+
self.run_worker(lambda: self._run_turn(text), thread=True, exclusive=False, group="turn")
|
|
262
|
+
|
|
263
|
+
def _run_turn(self, task: str) -> None:
|
|
264
|
+
try:
|
|
265
|
+
self._drive(task)
|
|
266
|
+
except transport.SignedOut:
|
|
267
|
+
self._show(Line(self._signed_out_line(), "problem"))
|
|
268
|
+
except transport.Refused as refused:
|
|
269
|
+
self._show(Line(str(refused), "problem"))
|
|
270
|
+
except transport.Unreachable:
|
|
271
|
+
self._show(Line(UNREACHABLE, "problem"))
|
|
272
|
+
except Exception: # noqa: BLE001 - a turn that breaks says so in plain words; the screen stays usable
|
|
273
|
+
self._show(Line("Something went wrong showing that. Check the chat for what happened.", "problem"))
|
|
274
|
+
finally:
|
|
275
|
+
self.call_from_thread(self._turn_ended)
|
|
276
|
+
|
|
277
|
+
def _turn_ended(self) -> None:
|
|
278
|
+
self.running = False
|
|
279
|
+
self.waiting_for_ok = False
|
|
280
|
+
self._status("")
|
|
281
|
+
|
|
282
|
+
def _drive(self, task: str) -> None:
|
|
283
|
+
tick = 0
|
|
284
|
+
while True:
|
|
285
|
+
origin, bearer, session_id = self._credential()
|
|
286
|
+
try:
|
|
287
|
+
if self.login:
|
|
288
|
+
stream = self._with_refresh(lambda b: transport.post_run(origin, b, session_id, task, tick))
|
|
289
|
+
else:
|
|
290
|
+
stream = transport.post_run(origin, bearer, session_id, task, tick)
|
|
291
|
+
except transport.Refused as refused:
|
|
292
|
+
if self.login and "session is gone" in str(refused):
|
|
293
|
+
self.login.session_id = None
|
|
294
|
+
self._show(Line("That session is gone, so this starts a new one.", "step"))
|
|
295
|
+
continue
|
|
296
|
+
raise
|
|
297
|
+
seen = 0
|
|
298
|
+
next_tick: int | None = None
|
|
299
|
+
finished: str | None = None
|
|
300
|
+
try:
|
|
301
|
+
for event in stream.events:
|
|
302
|
+
seen += 1
|
|
303
|
+
kind = event.get("event")
|
|
304
|
+
if kind == "checkpoint":
|
|
305
|
+
last = event.get("lastTick")
|
|
306
|
+
next_tick = last if isinstance(last, int) else tick
|
|
307
|
+
elif kind == "done":
|
|
308
|
+
finished = str(event.get("status") or "completed")
|
|
309
|
+
elif kind == "error":
|
|
310
|
+
finished = "error"
|
|
311
|
+
self._render_event(event)
|
|
312
|
+
except transport.Unreachable:
|
|
313
|
+
self._show(Line("The connection dropped. Following the run from what it logged…", "step"))
|
|
314
|
+
if finished == "completed" or finished == "error":
|
|
315
|
+
return
|
|
316
|
+
if finished == "awaiting_approval" or finished == "awaiting_plan":
|
|
317
|
+
self.waiting_for_ok = True
|
|
318
|
+
self.call_from_thread(self._status, "Waiting for your OK. Esc stops watching here; the change keeps waiting in the chat.")
|
|
319
|
+
self._follow(stream.run_id, seen, session_id)
|
|
320
|
+
return
|
|
321
|
+
if next_tick is not None:
|
|
322
|
+
tick = next_tick
|
|
323
|
+
continue
|
|
324
|
+
self._follow(stream.run_id, seen, session_id)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
def _follow(self, run_id: str | None, seq: int, session_id: str) -> None:
|
|
328
|
+
"""Read the run's own log until it ends: a change approved in the chat, or a stream that dropped, shows as it went."""
|
|
329
|
+
if not run_id:
|
|
330
|
+
self._show(Line("Follow the rest of this in the chat beside this terminal.", "step"))
|
|
331
|
+
return
|
|
332
|
+
current_run = run_id
|
|
333
|
+
while not self._stop_following.is_set() and not self._quitting.is_set():
|
|
334
|
+
origin, bearer, _ = self._credential()
|
|
335
|
+
if self.login:
|
|
336
|
+
events, seq, current_run = self._with_refresh(lambda b: transport.read_events(origin, b, session_id, current_run, seq))
|
|
337
|
+
else:
|
|
338
|
+
events, seq, current_run = transport.read_events(origin, bearer, session_id, current_run, seq)
|
|
339
|
+
ended = False
|
|
340
|
+
for event in events:
|
|
341
|
+
self._render_event(event)
|
|
342
|
+
kind = event.get("event")
|
|
343
|
+
if kind == "done":
|
|
344
|
+
status = str(event.get("status") or "completed")
|
|
345
|
+
ended = status not in ("awaiting_approval", "awaiting_plan")
|
|
346
|
+
elif kind == "error":
|
|
347
|
+
ended = True
|
|
348
|
+
elif kind == "tool_call":
|
|
349
|
+
self.waiting_for_ok = False
|
|
350
|
+
self.call_from_thread(self._status, "Working…")
|
|
351
|
+
if ended:
|
|
352
|
+
return
|
|
353
|
+
if self._stop_following.wait(_EVENT_POLL_S):
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
def _render_event(self, event: dict[str, Any]) -> None:
|
|
357
|
+
where = APPROVE_BESIDE
|
|
358
|
+
if self.login and self.login.session_id:
|
|
359
|
+
where = f"Approve or deny it in Nexplora: {self.login.origin}/code?codeSession={self.login.session_id}"
|
|
360
|
+
line = line_for(event, where)
|
|
361
|
+
if line:
|
|
362
|
+
self._show(line)
|
|
363
|
+
|
|
364
|
+
# ── the screen ─────────────────────────────────────────────────────────────
|
|
365
|
+
def _show(self, line: Line) -> None:
|
|
366
|
+
self.call_from_thread(self._write, line)
|
|
367
|
+
|
|
368
|
+
def _write(self, line: Line) -> None:
|
|
369
|
+
if not self.query("#transcript"):
|
|
370
|
+
return
|
|
371
|
+
transcript = self.query_one("#transcript", VerticalScroll)
|
|
372
|
+
if line.tone == "assistant" and self._last_assistant is not None:
|
|
373
|
+
text = line.text if line.replace_assistant else f"{self._last_assistant_text}\n\n{line.text}"
|
|
374
|
+
self._last_assistant.update(Text(text))
|
|
375
|
+
self._last_assistant_text = text
|
|
376
|
+
else:
|
|
377
|
+
widget = Static(Text(line.text, style=_TONE_STYLE.get(line.tone, "")), classes="line")
|
|
378
|
+
transcript.mount(widget)
|
|
379
|
+
self._last_assistant = widget if line.tone == "assistant" else None
|
|
380
|
+
self._last_assistant_text = line.text if line.tone == "assistant" else ""
|
|
381
|
+
transcript.scroll_end(animate=False)
|
|
382
|
+
|
|
383
|
+
def _status(self, text: str) -> None:
|
|
384
|
+
if self.query("#status"):
|
|
385
|
+
self.query_one("#status", Static).update(Text(text))
|
|
386
|
+
|
|
387
|
+
def action_page(self, direction: int) -> None:
|
|
388
|
+
if self.query("#transcript"):
|
|
389
|
+
box = self.query_one("#transcript", VerticalScroll)
|
|
390
|
+
if direction < 0:
|
|
391
|
+
box.scroll_page_up(animate=False)
|
|
392
|
+
else:
|
|
393
|
+
box.scroll_page_down(animate=False)
|
|
394
|
+
|
|
395
|
+
def action_stop_run(self) -> None:
|
|
396
|
+
if not self.running:
|
|
397
|
+
return
|
|
398
|
+
self._stop_following.set()
|
|
399
|
+
if self.waiting_for_ok:
|
|
400
|
+
return # nothing is running: it is waiting on the person, and Esc here must not throw that away
|
|
401
|
+
self._status("Stopping…")
|
|
402
|
+
self.run_worker(self._stop_on_server, thread=True, exclusive=False, group="stop")
|
|
403
|
+
|
|
404
|
+
def _stop_on_server(self) -> None:
|
|
405
|
+
try:
|
|
406
|
+
origin, bearer, session_id = self._credential()
|
|
407
|
+
if self.login:
|
|
408
|
+
self._with_refresh(lambda b: transport.stop_run(origin, b, session_id))
|
|
409
|
+
else:
|
|
410
|
+
transport.stop_run(origin, bearer, session_id)
|
|
411
|
+
except (transport.SignedOut, transport.Refused, transport.Unreachable):
|
|
412
|
+
self._show(Line("Couldn't stop it from here. Press Stop in the chat.", "problem"))
|
|
413
|
+
|
|
414
|
+
async def action_quit(self) -> None:
|
|
415
|
+
self._quitting.set()
|
|
416
|
+
self._stop_following.set()
|
|
417
|
+
if self.running:
|
|
418
|
+
self.exit_note = "NX may still be working in this session. It shows in the chat."
|
|
419
|
+
self.exit()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
__all__ = ["NxploraApp", "safe_origin"]
|
nxplora_cli/auth.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""How `nx` is signed in. Nothing here prints, logs or puts a credential anywhere but the one request it is for.
|
|
2
|
+
|
|
3
|
+
In a Nexplora Code session the app writes a short-lived session ticket to a private file (never the person's login). On any
|
|
4
|
+
other computer the person signs in once with a short link and code, and the result stays in a 0600 file.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
DEFAULT_ORIGIN = "https://nexplora.ai"
|
|
18
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "[::1]", "::1"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def safe_origin(origin: object) -> str | None:
|
|
22
|
+
"""Only https, or a local address. A credential is never sent anywhere else."""
|
|
23
|
+
if not isinstance(origin, str):
|
|
24
|
+
return None
|
|
25
|
+
parts = urlsplit(origin.strip())
|
|
26
|
+
if parts.path not in ("", "/") or parts.query or parts.fragment or parts.username or parts.password:
|
|
27
|
+
return None
|
|
28
|
+
if parts.scheme == "https" and parts.hostname:
|
|
29
|
+
return f"https://{parts.netloc}"
|
|
30
|
+
if parts.scheme == "http" and parts.hostname in _LOCAL_HOSTS:
|
|
31
|
+
return f"http://{parts.netloc}"
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _home() -> Path:
|
|
36
|
+
return Path(os.path.expanduser("~"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _private(path: Path) -> bool:
|
|
40
|
+
"""Ours, and nobody else can read it."""
|
|
41
|
+
try:
|
|
42
|
+
info = path.lstat()
|
|
43
|
+
except OSError:
|
|
44
|
+
return False
|
|
45
|
+
return stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() and not (info.st_mode & 0o077)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def session_file_paths() -> tuple[Path, Path]:
|
|
49
|
+
"""Same order the app writes them in: tmpfs first, then the home folder."""
|
|
50
|
+
return (Path(f"/dev/shm/nx-cli-{os.getuid()}/session.json"), _home() / ".nx-cli" / "session.json")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def on_nexplora_computer() -> bool:
|
|
54
|
+
"""A Code session's computer. Its terminal is set up by the app, which leaves this folder behind."""
|
|
55
|
+
return (_home() / ".nx-pty").is_dir()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SessionTicket:
|
|
60
|
+
origin: str
|
|
61
|
+
session_id: str
|
|
62
|
+
ticket: str
|
|
63
|
+
expires_at_ms: int
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"SessionTicket(session_id={self.session_id!r})"
|
|
67
|
+
|
|
68
|
+
def fresh(self, now_ms: float | None = None) -> bool:
|
|
69
|
+
return self.expires_at_ms > (time.time() * 1000 if now_ms is None else now_ms)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_session_ticket() -> SessionTicket | None:
|
|
73
|
+
"""The ticket on this computer, read fresh each time so a renewal is picked up without restarting."""
|
|
74
|
+
for path in session_file_paths():
|
|
75
|
+
if not _private(path):
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
doc = json.loads(path.read_text("utf-8"))
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
continue
|
|
81
|
+
origin = safe_origin(doc.get("origin"))
|
|
82
|
+
ticket, session_id, expires = doc.get("ticket"), doc.get("session_id"), doc.get("expires_at_ms")
|
|
83
|
+
if origin and isinstance(ticket, str) and ticket and isinstance(session_id, str) and session_id and isinstance(expires, (int, float)):
|
|
84
|
+
return SessionTicket(origin, session_id, ticket, int(expires))
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def credentials_path() -> Path:
|
|
89
|
+
return _home() / ".nx-cli" / "credentials.json"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class DeviceLogin:
|
|
94
|
+
origin: str
|
|
95
|
+
access_token: str
|
|
96
|
+
refresh_token: str
|
|
97
|
+
device_session_id: str | None = None
|
|
98
|
+
session_id: str | None = None
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"DeviceLogin(origin={self.origin!r})"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_device_login(origin: str | None = None) -> DeviceLogin | None:
|
|
105
|
+
path = credentials_path()
|
|
106
|
+
if not _private(path):
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
doc = json.loads(path.read_text("utf-8"))
|
|
110
|
+
except (OSError, ValueError):
|
|
111
|
+
return None
|
|
112
|
+
stored = safe_origin(doc.get("origin"))
|
|
113
|
+
access, refresh = doc.get("access_token"), doc.get("refresh_token")
|
|
114
|
+
if not stored or not isinstance(access, str) or not access or not isinstance(refresh, str):
|
|
115
|
+
return None
|
|
116
|
+
if origin and stored != origin:
|
|
117
|
+
return None
|
|
118
|
+
session_id = doc.get("session_id")
|
|
119
|
+
device_session_id = doc.get("device_session_id")
|
|
120
|
+
return DeviceLogin(
|
|
121
|
+
stored, access, refresh,
|
|
122
|
+
device_session_id if isinstance(device_session_id, str) else None,
|
|
123
|
+
session_id if isinstance(session_id, str) else None,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def save_device_login(login: DeviceLogin) -> None:
|
|
128
|
+
path = credentials_path()
|
|
129
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
130
|
+
os.chmod(path.parent, 0o700)
|
|
131
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".credentials.") # mkstemp creates it 0600
|
|
132
|
+
try:
|
|
133
|
+
with os.fdopen(fd, "w", encoding="utf-8") as out:
|
|
134
|
+
json.dump({
|
|
135
|
+
"origin": login.origin, "access_token": login.access_token, "refresh_token": login.refresh_token,
|
|
136
|
+
"device_session_id": login.device_session_id, "session_id": login.session_id,
|
|
137
|
+
}, out)
|
|
138
|
+
os.replace(tmp, path)
|
|
139
|
+
except BaseException:
|
|
140
|
+
try:
|
|
141
|
+
os.unlink(tmp)
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def forget_device_login() -> None:
|
|
148
|
+
try:
|
|
149
|
+
credentials_path().unlink()
|
|
150
|
+
except OSError:
|
|
151
|
+
pass
|
nxplora_cli/events.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""What each thing the run really did looks like in the terminal. Nothing here is made up: no event, no line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
CREDIT_PAUSE = "paused: credit limit"
|
|
8
|
+
OUT_OF_CREDIT_LINE = "You're out of credit. Add more in Nexplora to keep going."
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Line:
|
|
13
|
+
text: str
|
|
14
|
+
tone: str # assistant | step | waiting | problem
|
|
15
|
+
replace_assistant: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def clean(text: object, limit: int = 60_000) -> str:
|
|
19
|
+
"""Words from the server may not move the cursor, change colours or fake a screen."""
|
|
20
|
+
if not isinstance(text, str):
|
|
21
|
+
return ""
|
|
22
|
+
return "".join(ch for ch in text[:limit] if ch in "\n\t" or (ord(ch) >= 0x20 and not 0x7f <= ord(ch) <= 0x9f))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def first_line(text: object, limit: int = 300) -> str:
|
|
26
|
+
cleaned = clean(text, 4_000).strip()
|
|
27
|
+
return cleaned.splitlines()[0][:limit] if cleaned else ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def line_for(event: dict[str, Any], where_to_approve: str) -> Line | None:
|
|
31
|
+
kind = event.get("event")
|
|
32
|
+
if kind == "assistant_text":
|
|
33
|
+
text = clean(event.get("text"))
|
|
34
|
+
if text.strip() == CREDIT_PAUSE:
|
|
35
|
+
return Line(OUT_OF_CREDIT_LINE, "problem")
|
|
36
|
+
return Line(text, "assistant") if text.strip() else None
|
|
37
|
+
if kind == "assistant_revision":
|
|
38
|
+
text = clean(event.get("text"))
|
|
39
|
+
return Line(text, "assistant", True) if text.strip() else None
|
|
40
|
+
if kind in ("plan", "build_plan"):
|
|
41
|
+
text = clean(event.get("text"))
|
|
42
|
+
return Line(text, "step") if text.strip() else None
|
|
43
|
+
if kind == "tool_call":
|
|
44
|
+
summary = first_line(event.get("summary")) or first_line(event.get("tool"))
|
|
45
|
+
return Line(f"· {summary}", "step") if summary else None
|
|
46
|
+
if kind == "tool_result" and event.get("ok") is False:
|
|
47
|
+
detail = first_line(event.get("detail")) or first_line(event.get("stderr"))
|
|
48
|
+
return Line(f" That step didn't work{': ' + detail if detail else '.'}", "problem")
|
|
49
|
+
if kind == "file_changed":
|
|
50
|
+
path = first_line(event.get("path"))
|
|
51
|
+
return Line(f"· {clean(event.get('changeType')) or 'changed'} {path}", "step") if path else None
|
|
52
|
+
if kind == "approval_required":
|
|
53
|
+
summary = first_line(event.get("summary")) or "A change"
|
|
54
|
+
return Line(f"Waiting for your OK: {summary}\n{where_to_approve}", "waiting")
|
|
55
|
+
if kind == "images_generated":
|
|
56
|
+
urls = event.get("urls")
|
|
57
|
+
shown = [clean(u, 2_000) for u in urls if isinstance(u, str)] if isinstance(urls, list) else []
|
|
58
|
+
return Line("\n".join(shown), "step") if shown else None
|
|
59
|
+
if kind == "video_pending":
|
|
60
|
+
return Line("A video is being made. It will show in the chat.", "step")
|
|
61
|
+
if kind in ("web_operator_launch", "computer_operator_launch", "mission_operator_launch"):
|
|
62
|
+
return Line("This part runs outside the terminal. Follow it in Nexplora.", "step")
|
|
63
|
+
if kind == "halt":
|
|
64
|
+
detail = first_line(event.get("detail"))
|
|
65
|
+
reason = event.get("reason")
|
|
66
|
+
if reason == "aborted":
|
|
67
|
+
return Line("Stopped.", "step")
|
|
68
|
+
if reason in ("awaiting_approval", "awaiting_plan", "maxduration_checkpoint", "done", "session_busy"):
|
|
69
|
+
return None
|
|
70
|
+
return Line(detail or "The run stopped before it finished.", "problem")
|
|
71
|
+
if kind == "error":
|
|
72
|
+
from .transport import friendly
|
|
73
|
+
|
|
74
|
+
return Line(friendly(200, str(event.get("code") or ""), ""), "problem")
|
|
75
|
+
return None
|
nxplora_cli/opening.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_INDEX_SCAN_LIMIT = 4096
|
|
14
|
+
_INDEX_STAT = re.compile(
|
|
15
|
+
r" ctime: \d+:\d+\n mtime: (\d+):(\d+)\n"
|
|
16
|
+
r" dev: \d+\tino: \d+\n uid: \d+\tgid: \d+\n"
|
|
17
|
+
r" size: (\d+)\tflags: [0-9a-f]+\n"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _unstaged_paths(cwd: Path, index: str) -> list[str]:
|
|
22
|
+
# --stage --debug emits a NUL-terminated mode/OID/stage/path record,
|
|
23
|
+
# followed by five lines of index-only stat data. Never split paths on LF.
|
|
24
|
+
deadline = time.monotonic() + 0.7
|
|
25
|
+
offset = 0
|
|
26
|
+
changed = []
|
|
27
|
+
for _ in range(_INDEX_SCAN_LIMIT):
|
|
28
|
+
if time.monotonic() >= deadline:
|
|
29
|
+
break
|
|
30
|
+
end = index.find("\0", offset)
|
|
31
|
+
if end < 0:
|
|
32
|
+
break
|
|
33
|
+
entry = index[offset:end]
|
|
34
|
+
metadata = _INDEX_STAT.match(index, end + 1)
|
|
35
|
+
if metadata is None:
|
|
36
|
+
break # Unknown debug format: keep the other sources of context.
|
|
37
|
+
offset = metadata.end()
|
|
38
|
+
mode, separator, path = entry.partition("\t")
|
|
39
|
+
if not separator or mode.startswith("160000 "):
|
|
40
|
+
continue # Do not inspect gitlinks/submodule directories.
|
|
41
|
+
try:
|
|
42
|
+
info = os.lstat(cwd / path)
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
changed.append(path)
|
|
45
|
+
continue
|
|
46
|
+
seconds, nanoseconds, size = map(int, metadata.groups())
|
|
47
|
+
# Some Git platforms store only whole-second index timestamps.
|
|
48
|
+
if (info.st_size != size
|
|
49
|
+
or info.st_mtime_ns // 1_000_000_000 != seconds
|
|
50
|
+
or (nanoseconds and info.st_mtime_ns % 1_000_000_000 != nanoseconds)):
|
|
51
|
+
changed.append(path)
|
|
52
|
+
return changed
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class WorkspaceContext:
|
|
57
|
+
directory: str
|
|
58
|
+
branch: str | None
|
|
59
|
+
changed_files: tuple[str, ...]
|
|
60
|
+
latest_commit: str | None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def has_work(self) -> bool:
|
|
64
|
+
return bool(self.branch or self.changed_files or self.latest_commit)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def inspect_workspace(directory: str | None = None) -> WorkspaceContext:
|
|
68
|
+
cwd = Path(directory or os.getcwd())
|
|
69
|
+
# Startup may inspect an untrusted checkout before the user signs in.
|
|
70
|
+
# Do not inherit credentials, Git config injection, or user/system config.
|
|
71
|
+
env = {
|
|
72
|
+
"PATH": os.environ.get("PATH", os.defpath),
|
|
73
|
+
"LC_ALL": "C",
|
|
74
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
75
|
+
"GIT_CONFIG_SYSTEM": os.devnull,
|
|
76
|
+
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
77
|
+
"GIT_OPTIONAL_LOCKS": "0",
|
|
78
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
79
|
+
# Partial clones can otherwise invoke repo-configured remote helpers.
|
|
80
|
+
"GIT_NO_LAZY_FETCH": "1",
|
|
81
|
+
"GIT_ALLOW_PROTOCOL": "",
|
|
82
|
+
}
|
|
83
|
+
if os.name == "nt" and "SystemRoot" in os.environ:
|
|
84
|
+
env["SystemRoot"] = os.environ["SystemRoot"]
|
|
85
|
+
|
|
86
|
+
def git(*args: str) -> str | None:
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
[
|
|
90
|
+
"git", "-c", "core.fsmonitor=false",
|
|
91
|
+
"-c", "core.untrackedCache=false",
|
|
92
|
+
"-c", f"core.hooksPath={os.devnull}", *args,
|
|
93
|
+
],
|
|
94
|
+
cwd=cwd, env=env, capture_output=True, text=True,
|
|
95
|
+
encoding="utf-8", errors="replace", timeout=0.7, check=False,
|
|
96
|
+
)
|
|
97
|
+
except (OSError, subprocess.TimeoutExpired, UnicodeDecodeError, ValueError):
|
|
98
|
+
return None
|
|
99
|
+
return result.stdout if result.returncode == 0 else None
|
|
100
|
+
|
|
101
|
+
# Never inspect worktree contents: status/worktree diffs can run filters or
|
|
102
|
+
# enter submodules. The plumbing command compares index paths with HEAD;
|
|
103
|
+
# unlike `diff`, it has no auto-refresh fallback or configured order file.
|
|
104
|
+
# Staged gitlinks remain visible. Fall back to index paths without HEAD.
|
|
105
|
+
staged = git(
|
|
106
|
+
"diff-index", "--cached", "--name-only", "--no-ext-diff", "--no-textconv",
|
|
107
|
+
"--no-renames", "--ignore-submodules=none", "--no-relative",
|
|
108
|
+
"-z", "HEAD", "--",
|
|
109
|
+
)
|
|
110
|
+
if staged is None and git("rev-parse", "--verify", "-q", "HEAD") is None:
|
|
111
|
+
staged = git("ls-files", "-z")
|
|
112
|
+
# ls-files reads index metadata and walks directory names only. Python
|
|
113
|
+
# compares stat data without opening files; this is deliberately a bounded,
|
|
114
|
+
# best-effort hint (same-size edits preserving mtime may be missed).
|
|
115
|
+
unstaged = _unstaged_paths(cwd, git("ls-files", "--stage", "--debug", "-z") or "")
|
|
116
|
+
untracked = git(
|
|
117
|
+
"ls-files", "-z", "--others", "--exclude-standard",
|
|
118
|
+
"--directory", "--no-empty-directory",
|
|
119
|
+
)
|
|
120
|
+
changed = dict.fromkeys(
|
|
121
|
+
unstaged + [path for path in (staged or "").split("\0") if path]
|
|
122
|
+
+ [path for path in (untracked or "").split("\0") if path]
|
|
123
|
+
)
|
|
124
|
+
return WorkspaceContext(
|
|
125
|
+
directory=str(cwd),
|
|
126
|
+
branch=(git("symbolic-ref", "--short", "-q", "HEAD") or "").strip() or None,
|
|
127
|
+
changed_files=tuple(changed),
|
|
128
|
+
latest_commit=(git(
|
|
129
|
+
"log", "-1", "--no-show-signature", "--no-use-mailmap", "--format=%s",
|
|
130
|
+
) or "").strip() or None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def default_opening(context: WorkspaceContext, chooser: Callable[[list[str]], str] | None = None) -> str:
|
|
135
|
+
choose = chooser or random.SystemRandom().choice
|
|
136
|
+
if context.has_work:
|
|
137
|
+
branch = f" on `{context.branch}`" if context.branch else ""
|
|
138
|
+
extra = f" The last move was `{context.latest_commit}`." if context.latest_commit else ""
|
|
139
|
+
if context.changed_files:
|
|
140
|
+
focus = context.changed_files[0]
|
|
141
|
+
extra += f" I see work in `{focus}`."
|
|
142
|
+
return choose([
|
|
143
|
+
f"Welcome back{branch}.{extra} Want to finish it, inspect the diff, or keep building?",
|
|
144
|
+
f"I found an active workspace{branch}.{extra} What should we continue?",
|
|
145
|
+
f"Back in the work{branch}.{extra} Shall we pick up where you left off?",
|
|
146
|
+
])
|
|
147
|
+
return choose([
|
|
148
|
+
"Hey — how are you doing? What’s on your mind?",
|
|
149
|
+
"Hey, what’s up? What are we building today?",
|
|
150
|
+
"Good to see you. What’s cooking?",
|
|
151
|
+
"Hey — where should we start?",
|
|
152
|
+
"What are you thinking about? NX is ready when you are.",
|
|
153
|
+
"Morning. What would make today’s work feel like a win?",
|
|
154
|
+
"Hey there — what’s the first thing you want to move forward?",
|
|
155
|
+
"What’s alive in your head right now?",
|
|
156
|
+
"Welcome in. Do you want to explore, make, or fix something?",
|
|
157
|
+
"Hey — got a spark to chase?",
|
|
158
|
+
"What are we making better today?",
|
|
159
|
+
"I’m here. What should we look at first?",
|
|
160
|
+
"What’s the shape of the problem you’re carrying in?",
|
|
161
|
+
"Hey — want to start with an idea or an action?",
|
|
162
|
+
"What do you want to get unstuck?",
|
|
163
|
+
"Good to have you back. What’s worth your attention right now?",
|
|
164
|
+
"What’s the interesting thing on your desk today?",
|
|
165
|
+
"Hey — should we make something, understand something, or ship something?",
|
|
166
|
+
"What would be useful to finish before you leave this session?",
|
|
167
|
+
"Tell me what’s brewing.",
|
|
168
|
+
"What’s the next good move?",
|
|
169
|
+
"Hey — where is your attention pulling you?",
|
|
170
|
+
"What do you want NX to help carry today?",
|
|
171
|
+
"What’s the question behind the task?",
|
|
172
|
+
"Welcome. What are we curious about?",
|
|
173
|
+
"Hey — want to turn a rough thought into a plan?",
|
|
174
|
+
"What’s the thing you keep meaning to get back to?",
|
|
175
|
+
"What should we make clear first?",
|
|
176
|
+
"I’m ready when you are. What’s happening?",
|
|
177
|
+
"Hey — what deserves a closer look?",
|
|
178
|
+
"What are you hoping to discover today?",
|
|
179
|
+
"Want to start small or take on the big thing?",
|
|
180
|
+
"What’s changing in your world right now?",
|
|
181
|
+
"Hey — what can we make lighter?",
|
|
182
|
+
"What would you like to have in motion by the end of this?",
|
|
183
|
+
"What’s the story behind today’s work?",
|
|
184
|
+
"Welcome back to the workbench. What’s next?",
|
|
185
|
+
"Hey — do you have a direction, or should we find one together?",
|
|
186
|
+
"What’s the most useful place to begin?",
|
|
187
|
+
"What are we solving for?",
|
|
188
|
+
"Hey — bring me the half-formed version.",
|
|
189
|
+
"What’s on your mind, even if it’s not fully formed yet?",
|
|
190
|
+
"Should we follow the signal or inspect the noise?",
|
|
191
|
+
"What do you want to make real today?",
|
|
192
|
+
"Hey — what’s calling for your attention?",
|
|
193
|
+
])
|
|
194
|
+
|
nxplora_cli/transport.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The whole of what `nx` says to Nexplora: send a message, follow what really happened, stop, and sign in.
|
|
2
|
+
|
|
3
|
+
Standard library only. No brain, no model, no provider: every step is the same run a message typed in the chat makes.
|
|
4
|
+
Redirects are never followed, so a credential only ever goes to the address it was issued for.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import http.client
|
|
9
|
+
import json
|
|
10
|
+
import ssl
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Callable, Iterator
|
|
14
|
+
from urllib.parse import urlencode, urlsplit
|
|
15
|
+
|
|
16
|
+
_TIMEOUT_S = 30
|
|
17
|
+
# A run's stream may sit quiet for a long step. The server's own deadline ends it, so this is only for a dead connection.
|
|
18
|
+
_STREAM_TIMEOUT_S = 660
|
|
19
|
+
RUN_ID_HEADER = "x-nx-code-run-id"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SignedOut(Exception):
|
|
23
|
+
"""The credential was refused."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Refused(Exception):
|
|
27
|
+
"""The server answered no. str() is one plain line for the person."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Unreachable(Exception):
|
|
31
|
+
"""Nothing answered."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def friendly(status: int, code: str, reason: str) -> str:
|
|
35
|
+
"""One plain line for a refusal. A server's words are used only where they are already written for the person."""
|
|
36
|
+
if status == 402 or "credit" in code or "budget" in code:
|
|
37
|
+
return "You're out of credit. Add more in Nexplora to keep going."
|
|
38
|
+
if code == "session_not_found" or status == 404:
|
|
39
|
+
return "This session is gone. Open a new one in Nexplora."
|
|
40
|
+
if code == "session_busy":
|
|
41
|
+
return "This session is already working on something. Wait for it to finish, or stop it in the chat."
|
|
42
|
+
if code == "busy" or status == 429:
|
|
43
|
+
return "Nexplora is busy right now. Try again in a moment."
|
|
44
|
+
if code in {"workspace_expired", "workspace_unavailable"}:
|
|
45
|
+
return "This session's computer isn't available. Open a new session in Nexplora."
|
|
46
|
+
if code in {"approval_elsewhere", "ticket_message_only", "ticket_other_session"}:
|
|
47
|
+
return "This terminal can't do that. Do it in the chat beside it."
|
|
48
|
+
if status == 403 and reason:
|
|
49
|
+
return reason # a plan or compute refusal: the same words the app shows
|
|
50
|
+
if code == "model_unavailable":
|
|
51
|
+
return "That model isn't available right now. Try again in a moment."
|
|
52
|
+
if status >= 500 or code in {"backend_unavailable", "plan_unavailable", "not_configured", "request_timeout"}:
|
|
53
|
+
return "Nexplora couldn't do that just now. Try again in a moment."
|
|
54
|
+
return "That didn't go through. Try again in a moment."
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _connection(origin: str, timeout: float) -> http.client.HTTPConnection:
|
|
58
|
+
parts = urlsplit(origin)
|
|
59
|
+
if parts.scheme == "https":
|
|
60
|
+
context = ssl.create_default_context()
|
|
61
|
+
try:
|
|
62
|
+
import certifi
|
|
63
|
+
|
|
64
|
+
context.load_verify_locations(certifi.where())
|
|
65
|
+
except (ImportError, OSError, ssl.SSLError):
|
|
66
|
+
pass
|
|
67
|
+
return http.client.HTTPSConnection(parts.hostname or "", parts.port, timeout=timeout, context=context)
|
|
68
|
+
return http.client.HTTPConnection(parts.hostname or "", parts.port, timeout=timeout)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _headers(bearer: str | None, has_body: bool, accept: str = "application/json") -> dict[str, str]:
|
|
72
|
+
headers = {"accept": accept, "user-agent": "nx-cli"}
|
|
73
|
+
if has_body:
|
|
74
|
+
headers["content-type"] = "application/json"
|
|
75
|
+
if bearer:
|
|
76
|
+
headers["authorization"] = f"Bearer {bearer}"
|
|
77
|
+
return headers
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _json_request(origin: str, method: str, path: str, bearer: str | None = None, body: object | None = None) -> tuple[int, dict[str, Any]]:
|
|
81
|
+
conn = _connection(origin, _TIMEOUT_S)
|
|
82
|
+
try:
|
|
83
|
+
conn.request(method, path, json.dumps(body) if body is not None else None, _headers(bearer, body is not None))
|
|
84
|
+
res = conn.getresponse()
|
|
85
|
+
raw = res.read(2_000_000)
|
|
86
|
+
try:
|
|
87
|
+
parsed = json.loads(raw) if raw else {}
|
|
88
|
+
except ValueError:
|
|
89
|
+
parsed = {}
|
|
90
|
+
return res.status, parsed if isinstance(parsed, dict) else {}
|
|
91
|
+
except (OSError, http.client.HTTPException) as err:
|
|
92
|
+
raise Unreachable() from err
|
|
93
|
+
finally:
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class RunStream:
|
|
99
|
+
run_id: str | None
|
|
100
|
+
events: Iterator[dict[str, Any]]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def post_run(origin: str, bearer: str, session_id: str, task: str, resume_from_tick: int = 0) -> RunStream:
|
|
104
|
+
"""One run request. A refusal raises; otherwise the events arrive as the server emits them."""
|
|
105
|
+
body: dict[str, object] = {"sessionId": session_id, "task": task, "idempotencyKey": f"nx-cli:{uuid.uuid4()}"}
|
|
106
|
+
if resume_from_tick > 0:
|
|
107
|
+
body["resumeFromTick"] = resume_from_tick
|
|
108
|
+
conn = _connection(origin, _STREAM_TIMEOUT_S)
|
|
109
|
+
try:
|
|
110
|
+
conn.request("POST", "/api/code/run", json.dumps(body), _headers(bearer, True, "application/x-ndjson"))
|
|
111
|
+
res = conn.getresponse()
|
|
112
|
+
except (OSError, http.client.HTTPException) as err:
|
|
113
|
+
conn.close()
|
|
114
|
+
raise Unreachable() from err
|
|
115
|
+
if res.status == 401:
|
|
116
|
+
conn.close()
|
|
117
|
+
raise SignedOut()
|
|
118
|
+
if res.status >= 300:
|
|
119
|
+
raw = res.read(64_000)
|
|
120
|
+
conn.close()
|
|
121
|
+
code, reason = "", ""
|
|
122
|
+
for line in raw.decode("utf-8", "replace").splitlines():
|
|
123
|
+
try:
|
|
124
|
+
doc = json.loads(line)
|
|
125
|
+
except ValueError:
|
|
126
|
+
continue
|
|
127
|
+
if isinstance(doc, dict):
|
|
128
|
+
code = str(doc.get("code") or doc.get("error") or "")
|
|
129
|
+
reason = str(doc.get("reason") or doc.get("message") or "")
|
|
130
|
+
break
|
|
131
|
+
raise Refused(friendly(res.status, code, reason))
|
|
132
|
+
return RunStream(res.getheader(RUN_ID_HEADER), _iter_events(conn, res))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _iter_events(conn: http.client.HTTPConnection, res: http.client.HTTPResponse) -> Iterator[dict[str, Any]]:
|
|
136
|
+
try:
|
|
137
|
+
while True:
|
|
138
|
+
line = res.readline(1_000_000)
|
|
139
|
+
if not line:
|
|
140
|
+
return
|
|
141
|
+
try:
|
|
142
|
+
doc = json.loads(line)
|
|
143
|
+
except ValueError:
|
|
144
|
+
continue
|
|
145
|
+
if isinstance(doc, dict) and isinstance(doc.get("event"), str):
|
|
146
|
+
yield doc
|
|
147
|
+
except (OSError, http.client.HTTPException) as err:
|
|
148
|
+
raise Unreachable() from err
|
|
149
|
+
finally:
|
|
150
|
+
conn.close()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def read_events(origin: str, bearer: str, session_id: str, run_id: str, seq: int) -> tuple[list[dict[str, Any]], int, str]:
|
|
154
|
+
"""What the session's run has logged since this mark, and the next mark."""
|
|
155
|
+
query = urlencode({"sessionId": session_id, "runId": run_id, "seq": seq})
|
|
156
|
+
status, doc = _json_request(origin, "GET", f"/api/nx-cli/session-events?{query}", bearer)
|
|
157
|
+
if status == 401:
|
|
158
|
+
raise SignedOut()
|
|
159
|
+
if status >= 300:
|
|
160
|
+
raise Refused(friendly(status, str(doc.get("error") or ""), ""))
|
|
161
|
+
events = doc.get("events")
|
|
162
|
+
mark = doc.get("mark")
|
|
163
|
+
next_seq, next_run = seq, run_id
|
|
164
|
+
if isinstance(mark, dict) and isinstance(mark.get("seq"), int) and isinstance(mark.get("runId"), str):
|
|
165
|
+
next_seq, next_run = mark["seq"], mark["runId"]
|
|
166
|
+
return [e for e in events if isinstance(e, dict)] if isinstance(events, list) else [], next_seq, next_run
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def stop_run(origin: str, bearer: str, session_id: str) -> bool:
|
|
170
|
+
status, doc = _json_request(origin, "POST", "/api/nx-cli/session-stop", bearer, {"sessionId": session_id})
|
|
171
|
+
if status == 401:
|
|
172
|
+
raise SignedOut()
|
|
173
|
+
return status < 300 and doc.get("ok") is True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_session(origin: str, bearer: str) -> str:
|
|
177
|
+
status, doc = _json_request(origin, "POST", "/api/nx-cli/sessions", bearer, {})
|
|
178
|
+
if status == 401:
|
|
179
|
+
raise SignedOut()
|
|
180
|
+
session_id = doc.get("sessionId")
|
|
181
|
+
if status >= 300 or not isinstance(session_id, str):
|
|
182
|
+
raise Refused(friendly(status, str(doc.get("error") or ""), str(doc.get("message") or "")))
|
|
183
|
+
return session_id
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class DeviceCode:
|
|
188
|
+
device_code: str
|
|
189
|
+
user_code: str
|
|
190
|
+
link: str
|
|
191
|
+
interval_s: float
|
|
192
|
+
expires_in_s: float
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def start_device_grant(origin: str, version: str, device_name: str) -> DeviceCode:
|
|
196
|
+
status, doc = _json_request(origin, "POST", "/api/oauth/device/code", None, {"client_name": "NX CLI", "client_version": version, "device_name": device_name})
|
|
197
|
+
device_code, user_code, link = doc.get("device_code"), doc.get("user_code"), doc.get("verification_uri_complete")
|
|
198
|
+
if status >= 300 or not isinstance(device_code, str) or not isinstance(user_code, str) or not isinstance(link, str):
|
|
199
|
+
raise Refused(friendly(status, str(doc.get("error") or ""), ""))
|
|
200
|
+
return DeviceCode(device_code, user_code, link, float(doc.get("interval") or 5), float(doc.get("expires_in") or 900))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def poll_device_grant(origin: str, device_code: str) -> dict[str, Any] | str:
|
|
204
|
+
"""The tokens, or "pending", "denied" or "expired"."""
|
|
205
|
+
status, doc = _json_request(origin, "POST", "/api/oauth/token", None, {"device_code": device_code})
|
|
206
|
+
if status < 300 and isinstance(doc.get("access_token"), str):
|
|
207
|
+
return doc
|
|
208
|
+
error = doc.get("error")
|
|
209
|
+
if error == "authorization_pending" or status >= 500:
|
|
210
|
+
return "pending"
|
|
211
|
+
return "denied" if error == "access_denied" else "expired"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def refresh_login(origin: str, refresh_token: str) -> tuple[str, str] | None:
|
|
215
|
+
status, doc = _json_request(origin, "POST", "/api/oauth/refresh", None, {"refresh_token": refresh_token})
|
|
216
|
+
access, refresh = doc.get("access_token"), doc.get("refresh_token")
|
|
217
|
+
return (access, refresh) if status < 300 and isinstance(access, str) and isinstance(refresh, str) else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def revoke_device_session(origin: str, bearer: str, device_session_id: str) -> None:
|
|
221
|
+
_json_request(origin, "DELETE", f"/api/oauth/sessions/{device_session_id}", bearer)
|