scriptit-cli 0.2.2__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.
- scriptit_cli/__init__.py +17 -0
- scriptit_cli/analytics.py +244 -0
- scriptit_cli/auth.py +553 -0
- scriptit_cli/client.py +42 -0
- scriptit_cli/commands.py +302 -0
- scriptit_cli/config.py +19 -0
- scriptit_cli/errors.py +21 -0
- scriptit_cli/main.py +184 -0
- scriptit_cli/output.py +79 -0
- scriptit_cli/remote.py +1306 -0
- scriptit_cli/remote_auth.py +464 -0
- scriptit_cli/update_check.py +104 -0
- scriptit_cli/util.py +84 -0
- scriptit_cli-0.2.2.dist-info/METADATA +599 -0
- scriptit_cli-0.2.2.dist-info/RECORD +18 -0
- scriptit_cli-0.2.2.dist-info/WHEEL +4 -0
- scriptit_cli-0.2.2.dist-info/entry_points.txt +2 -0
- scriptit_cli-0.2.2.dist-info/licenses/LICENSE +202 -0
scriptit_cli/commands.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Workstation-side commands for remote sandbox access.
|
|
2
|
+
|
|
3
|
+
``fs`` wraps the platform's file API; ``session`` manages the sticky anchor
|
|
4
|
+
session that remote shell commands run in; ``context`` fetches the complete
|
|
5
|
+
agent context; ``sandbox`` surfaces lifecycle state. These are client
|
|
6
|
+
conveniences — every other verb passes through and runs inside the sandbox.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from scriptit_cli import MIN_SANDBOX_CLI_VERSION
|
|
16
|
+
from scriptit_cli.analytics import analytics
|
|
17
|
+
from scriptit_cli.client import detect_client
|
|
18
|
+
from scriptit_cli.errors import ScriptItError
|
|
19
|
+
from scriptit_cli.output import emit, fail
|
|
20
|
+
from scriptit_cli.remote import ENV_SESSION, RemoteClient, update_state
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _session_urls(client: RemoteClient, session_id: Optional[str]) -> Dict[str, Optional[str]]:
|
|
24
|
+
session_url = f"{client.app_url}/app/s/{session_id}" if client.app_url and session_id else None
|
|
25
|
+
return {"session_url": session_url}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fetch_context_bundle(
|
|
29
|
+
client: RemoteClient,
|
|
30
|
+
sandbox_id: str,
|
|
31
|
+
session_id: str,
|
|
32
|
+
) -> Dict[str, Any]:
|
|
33
|
+
# The sandbox assembles the briefing from its own state (integrations,
|
|
34
|
+
# skills, its CLI version, mounted workspaces); this side contributes
|
|
35
|
+
# only what the sandbox cannot know — which session, the app origin the
|
|
36
|
+
# client logged into, and the client's compatibility floor.
|
|
37
|
+
context_data: Dict[str, Any] = {
|
|
38
|
+
"session_id": session_id,
|
|
39
|
+
**_session_urls(client, session_id),
|
|
40
|
+
"minimum_sandbox_version": MIN_SANDBOX_CLI_VERSION,
|
|
41
|
+
}
|
|
42
|
+
return client.agent_context(sandbox_id, context_data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _client_or_exit() -> RemoteClient:
|
|
46
|
+
try:
|
|
47
|
+
return RemoteClient()
|
|
48
|
+
except ScriptItError as exc:
|
|
49
|
+
fail(str(exc))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run(fn, *args: Any, **kwargs: Any) -> Any:
|
|
53
|
+
# OSError covers local-file failures (push of a missing file, pull into
|
|
54
|
+
# an unwritable path) — formatted like transport errors, no traceback.
|
|
55
|
+
try:
|
|
56
|
+
return fn(*args, **kwargs)
|
|
57
|
+
except (ScriptItError, OSError) as exc:
|
|
58
|
+
fail(str(exc))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def show_context() -> None:
|
|
62
|
+
"""Fetch and print the complete external-agent context for this session."""
|
|
63
|
+
client = _client_or_exit()
|
|
64
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
65
|
+
session_id, _, _ = client.anchor()
|
|
66
|
+
if not session_id:
|
|
67
|
+
fail("no current session; run `scriptit session new` first")
|
|
68
|
+
if not _run(client.session_exists, sandbox_id, session_id):
|
|
69
|
+
fail(f"session {session_id} not found in sandbox {sandbox_id}")
|
|
70
|
+
attributed_client = _run(client.ensure_session_attribution, sandbox_id, session_id)
|
|
71
|
+
if attributed_client:
|
|
72
|
+
update_state(client.state_key, client=attributed_client)
|
|
73
|
+
bundle = _run(_fetch_context_bundle, client, sandbox_id, session_id)
|
|
74
|
+
emit(bundle, lambda: print(bundle["markdown"]))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FsCommands:
|
|
78
|
+
"""Files in your sandbox. A relative path starts where `exec` runs, the
|
|
79
|
+
current session's directory (workspaces/<wid>/..., data_files/...)."""
|
|
80
|
+
|
|
81
|
+
def read(self, path: str) -> None:
|
|
82
|
+
"""Print a sandbox file to stdout (bytes pass through unmodified)."""
|
|
83
|
+
client = _client_or_exit()
|
|
84
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
85
|
+
path = _run(client.resolve_fs_path, sandbox_id, path)
|
|
86
|
+
data: bytes = _run(client.fs_read, sandbox_id, path)
|
|
87
|
+
sys.stdout.buffer.write(data)
|
|
88
|
+
sys.stdout.buffer.flush()
|
|
89
|
+
|
|
90
|
+
def write(self, path: str, content: str) -> None:
|
|
91
|
+
"""Write text content to a sandbox file (creates parent dirs)."""
|
|
92
|
+
client = _client_or_exit()
|
|
93
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
94
|
+
path = _run(client.resolve_fs_path, sandbox_id, path)
|
|
95
|
+
_run(client.fs_write, sandbox_id, path, content)
|
|
96
|
+
emit(
|
|
97
|
+
{"path": path, "bytes": len(content.encode("utf-8"))},
|
|
98
|
+
lambda: print(f"Wrote {path}"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def push(self, local: str, remote: str) -> None:
|
|
102
|
+
"""Upload a local file into the sandbox (binary-safe, ≤30MB)."""
|
|
103
|
+
client = _client_or_exit()
|
|
104
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
105
|
+
remote = _run(client.resolve_fs_path, sandbox_id, remote)
|
|
106
|
+
_run(client.fs_upload, sandbox_id, remote, local)
|
|
107
|
+
emit(
|
|
108
|
+
{"local": local, "remote": remote},
|
|
109
|
+
lambda: print(f"Pushed {local} -> {remote}"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def pull(self, remote: str, local: str) -> None:
|
|
113
|
+
"""Download a sandbox file to a local path (parents auto-created).
|
|
114
|
+
|
|
115
|
+
A directory destination gets the remote basename appended, so
|
|
116
|
+
``scriptit fs pull /remote/report.txt .`` lands ``./report.txt``.
|
|
117
|
+
"""
|
|
118
|
+
client = _client_or_exit()
|
|
119
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
120
|
+
remote = _run(client.resolve_fs_path, sandbox_id, remote)
|
|
121
|
+
data: bytes = _run(client.fs_read, sandbox_id, remote)
|
|
122
|
+
# A trailing slash names a directory even before it exists.
|
|
123
|
+
if os.path.isdir(local) or local.endswith(("/", os.sep)):
|
|
124
|
+
local = os.path.join(local, os.path.basename(remote.rstrip("/")))
|
|
125
|
+
|
|
126
|
+
def _write_local() -> None:
|
|
127
|
+
parent = os.path.dirname(local)
|
|
128
|
+
if parent:
|
|
129
|
+
os.makedirs(parent, exist_ok=True)
|
|
130
|
+
with open(local, "wb") as f:
|
|
131
|
+
f.write(data)
|
|
132
|
+
|
|
133
|
+
# An unwritable or invalid destination is an ordinary error, not a
|
|
134
|
+
# traceback — the same treatment the remote half already gets.
|
|
135
|
+
_run(_write_local)
|
|
136
|
+
emit(
|
|
137
|
+
{"remote": remote, "local": local, "bytes": len(data)},
|
|
138
|
+
lambda: print(f"Pulled {remote} -> {local} ({len(data)} bytes)"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_ANCHOR_SOURCES = {
|
|
143
|
+
"env": f"{ENV_SESSION} environment variable",
|
|
144
|
+
"state": "sticky anchor for this login",
|
|
145
|
+
"none": "none yet",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SessionCommands:
|
|
150
|
+
"""The anchor session remote commands run in (visible in the app UI)."""
|
|
151
|
+
|
|
152
|
+
def new(self) -> None:
|
|
153
|
+
"""Create a fresh anchor session and make it current."""
|
|
154
|
+
client = _client_or_exit()
|
|
155
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
156
|
+
session_id = _run(client.create_session, sandbox_id)
|
|
157
|
+
update_state(
|
|
158
|
+
client.state_key,
|
|
159
|
+
sandbox_id=sandbox_id,
|
|
160
|
+
session_id=session_id,
|
|
161
|
+
client=detect_client(),
|
|
162
|
+
)
|
|
163
|
+
# The funnel's first real step: a harness that gets this far has a
|
|
164
|
+
# working credential and a live sandbox to drive.
|
|
165
|
+
analytics.track("cli_session_created")
|
|
166
|
+
|
|
167
|
+
def _human() -> None:
|
|
168
|
+
print(f"Anchor session: {session_id} (sandbox {sandbox_id})")
|
|
169
|
+
|
|
170
|
+
payload: Dict[str, Any] = {
|
|
171
|
+
"session_id": session_id,
|
|
172
|
+
"sandbox_id": sandbox_id,
|
|
173
|
+
}
|
|
174
|
+
emit(payload, _human)
|
|
175
|
+
|
|
176
|
+
def current(self) -> None:
|
|
177
|
+
"""Show the current anchor session, where it came from, and its app URL."""
|
|
178
|
+
client = _client_or_exit()
|
|
179
|
+
session_id, sandbox_id, source = client.anchor()
|
|
180
|
+
# Carried here as well as in `context`: this is the command an agent
|
|
181
|
+
# runs when it already has a session and just needs to know which, and
|
|
182
|
+
# a link it cannot get here is a link it will try to build itself.
|
|
183
|
+
link = _session_urls(client, session_id)
|
|
184
|
+
|
|
185
|
+
def _human() -> None:
|
|
186
|
+
if not session_id:
|
|
187
|
+
print("No anchor session yet — one is created on first use.")
|
|
188
|
+
return
|
|
189
|
+
print(f"session: {session_id}")
|
|
190
|
+
print(f"sandbox: {sandbox_id}")
|
|
191
|
+
print(f"source: {_ANCHOR_SOURCES[source]}")
|
|
192
|
+
if link["session_url"]:
|
|
193
|
+
print(f"app: {link['session_url']}")
|
|
194
|
+
|
|
195
|
+
emit(
|
|
196
|
+
{
|
|
197
|
+
"session_id": session_id,
|
|
198
|
+
"sandbox_id": sandbox_id,
|
|
199
|
+
"anchor_source": source,
|
|
200
|
+
**link,
|
|
201
|
+
},
|
|
202
|
+
_human,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def use(self, session_id: str) -> None:
|
|
206
|
+
"""Point remote commands at an existing session id."""
|
|
207
|
+
client = _client_or_exit()
|
|
208
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
209
|
+
if not _run(client.session_exists, sandbox_id, session_id):
|
|
210
|
+
fail(f"session {session_id} not found in sandbox {sandbox_id}")
|
|
211
|
+
attributed_client = _run(client.ensure_session_attribution, sandbox_id, session_id)
|
|
212
|
+
update_state(
|
|
213
|
+
client.state_key,
|
|
214
|
+
sandbox_id=sandbox_id,
|
|
215
|
+
session_id=session_id,
|
|
216
|
+
client=attributed_client,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _human() -> None:
|
|
220
|
+
print(f"Anchor session set to {session_id}")
|
|
221
|
+
|
|
222
|
+
payload: Dict[str, Any] = {
|
|
223
|
+
"session_id": session_id,
|
|
224
|
+
"sandbox_id": sandbox_id,
|
|
225
|
+
}
|
|
226
|
+
emit(payload, _human)
|
|
227
|
+
|
|
228
|
+
def list(self, page_size: int = 20) -> None:
|
|
229
|
+
"""List recent sessions in the sandbox."""
|
|
230
|
+
client = _client_or_exit()
|
|
231
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
232
|
+
resp = _run(
|
|
233
|
+
client.request,
|
|
234
|
+
"GET",
|
|
235
|
+
f"/api/v1/sandbox/{sandbox_id}/proxy/sessions/list",
|
|
236
|
+
params={"pageSize": page_size},
|
|
237
|
+
timeout=60,
|
|
238
|
+
)
|
|
239
|
+
if resp.status_code != 200:
|
|
240
|
+
fail(f"sessions list failed ({resp.status_code})")
|
|
241
|
+
sessions = resp.json().get("sessions") or []
|
|
242
|
+
current, _, _ = client.anchor()
|
|
243
|
+
|
|
244
|
+
def _human() -> None:
|
|
245
|
+
for entry in sessions:
|
|
246
|
+
sid = entry.get("id") or entry.get("sessionId")
|
|
247
|
+
title = (entry.get("title") or "").strip() or "(untitled)"
|
|
248
|
+
print(f"{sid} {title}{' *' if sid == current else ''}")
|
|
249
|
+
|
|
250
|
+
emit({"current": current, "sessions": sessions}, _human)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class SandboxCommands:
|
|
254
|
+
"""Sandbox lifecycle, as seen from outside."""
|
|
255
|
+
|
|
256
|
+
def status(self) -> None:
|
|
257
|
+
"""Show your sandboxes and their states."""
|
|
258
|
+
client = _client_or_exit()
|
|
259
|
+
sandboxes = _run(client.list_sandboxes)
|
|
260
|
+
rows = []
|
|
261
|
+
for sb in sandboxes:
|
|
262
|
+
sid = sb.get("sandbox_id")
|
|
263
|
+
resp = _run(
|
|
264
|
+
client.request,
|
|
265
|
+
"GET",
|
|
266
|
+
f"/api/v1/sandbox/{sid}/status",
|
|
267
|
+
wake_on_503=False,
|
|
268
|
+
timeout=30,
|
|
269
|
+
)
|
|
270
|
+
rows.append(
|
|
271
|
+
{
|
|
272
|
+
"sandbox_id": sid,
|
|
273
|
+
"state": resp.json().get("state", "unknown")
|
|
274
|
+
if resp.status_code == 200
|
|
275
|
+
else "unknown",
|
|
276
|
+
"is_active": bool(sb.get("is_active")),
|
|
277
|
+
"workspaces": [
|
|
278
|
+
w.get("name") or w.get("workspace_id", "?")
|
|
279
|
+
for w in (sb.get("accessible_workspaces") or [])
|
|
280
|
+
],
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _human() -> None:
|
|
285
|
+
if not rows:
|
|
286
|
+
print("No sandbox yet — one is created on first use.")
|
|
287
|
+
return
|
|
288
|
+
for row in rows:
|
|
289
|
+
active = " (active)" if row["is_active"] else ""
|
|
290
|
+
names = ", ".join(row["workspaces"]) or "-"
|
|
291
|
+
print(f"{row['sandbox_id']} {row['state']}{active} workspaces: {names}")
|
|
292
|
+
|
|
293
|
+
emit({"sandboxes": rows}, _human)
|
|
294
|
+
|
|
295
|
+
def wake(self) -> None:
|
|
296
|
+
"""Wake the active sandbox (resume if paused) and wait until ready."""
|
|
297
|
+
client = _client_or_exit()
|
|
298
|
+
sandbox_id = _run(client.ensure_sandbox)
|
|
299
|
+
emit(
|
|
300
|
+
{"sandbox_id": sandbox_id, "state": "ready"},
|
|
301
|
+
lambda: print(f"Sandbox {sandbox_id} is ready."),
|
|
302
|
+
)
|
scriptit_cli/config.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Where the client keeps its state.
|
|
2
|
+
|
|
3
|
+
Credentials and the per-profile anchor-session state live in one directory, so
|
|
4
|
+
a machine is disconnected by removing it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _config_dir() -> str:
|
|
13
|
+
"""``$XDG_CONFIG_HOME/scriptit``, else ``~/.config/scriptit``.
|
|
14
|
+
|
|
15
|
+
The same directory the in-sandbox CLI uses, so a machine that has both
|
|
16
|
+
installed shares one set of credentials.
|
|
17
|
+
"""
|
|
18
|
+
xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
19
|
+
return os.path.join(xdg, "scriptit")
|
scriptit_cli/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every failure the user should see as one line rather than a traceback derives
|
|
4
|
+
from :class:`ScriptItError`. Handlers catch the base, so a new error type is
|
|
5
|
+
covered everywhere the day it is added — the previous per-type tuples meant
|
|
6
|
+
each new call site had to remember the full list, and one eventually would not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ScriptItError(RuntimeError):
|
|
13
|
+
"""Anything the CLI should report as a message, not a stack trace."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RemoteAuthError(ScriptItError):
|
|
17
|
+
"""Credential problems: not logged in, refresh failed, store unusable."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RemoteError(ScriptItError):
|
|
21
|
+
"""Transport-level failures talking to Script.it."""
|
scriptit_cli/main.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""``scriptit`` entry point.
|
|
2
|
+
|
|
3
|
+
Routing is by verb: ``exec`` and everything this client does not answer itself
|
|
4
|
+
go to the sandbox; :data:`~scriptit_cli.remote.CLIENT_COMMANDS` are handled
|
|
5
|
+
here. Nothing in this package imports or executes the Script.it runtime — the
|
|
6
|
+
sandbox's own CLI is what runs automation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import List, Tuple
|
|
16
|
+
|
|
17
|
+
import fire
|
|
18
|
+
|
|
19
|
+
from scriptit_cli import __version__, output, update_check
|
|
20
|
+
from scriptit_cli.analytics import analytics, safe_command
|
|
21
|
+
from scriptit_cli.auth import AuthCommands
|
|
22
|
+
from scriptit_cli.commands import FsCommands, SandboxCommands, SessionCommands, show_context
|
|
23
|
+
from scriptit_cli.output import emit, set_json_mode, warn
|
|
24
|
+
from scriptit_cli.remote import CLIENT_COMMANDS, remote_dispatch_if_applicable, remote_exec
|
|
25
|
+
|
|
26
|
+
JSON_FLAG = "--json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Commands whose output this client formats, so `--json` is ours to read.
|
|
30
|
+
# `exec` is absent on purpose: everything after it is the user's command, and
|
|
31
|
+
# consuming a `--json` out of it would change what runs.
|
|
32
|
+
JSON_COMMANDS = frozenset({"auth", "context", "fs", "session", "sandbox", "version"})
|
|
33
|
+
|
|
34
|
+
# ...minus the ones inside those groups that stream raw bytes instead of
|
|
35
|
+
# returning a result. `fs read` writes the file unmodified, which is the point
|
|
36
|
+
# of it — so rather than promise one JSON document and then not produce one,
|
|
37
|
+
# `--json` is a no-op there and the flag is simply consumed.
|
|
38
|
+
JSON_STREAMING_COMMANDS = frozenset({("fs", "read")})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_json_flag(argv: List[str]) -> Tuple[List[str], bool]:
|
|
42
|
+
"""Pull ``--json`` out of a client invocation, without touching data.
|
|
43
|
+
|
|
44
|
+
Two things are never ours to consume: a forwarded verb's flags, which
|
|
45
|
+
belong to the sandbox's CLI, and anything after ``--``, which is opaque
|
|
46
|
+
payload — `scriptit exec -- tool --json` must run `tool --json`.
|
|
47
|
+
|
|
48
|
+
A positional value that happens to spell ``--json`` is indistinguishable
|
|
49
|
+
from the flag at this layer (as it is to any flag parser); pass such a
|
|
50
|
+
value as ``--content=--json`` instead.
|
|
51
|
+
"""
|
|
52
|
+
if not argv or argv[0] not in JSON_COMMANDS:
|
|
53
|
+
return argv, False
|
|
54
|
+
cut = argv.index("--") if "--" in argv else len(argv)
|
|
55
|
+
head, tail = argv[:cut], argv[cut:]
|
|
56
|
+
if JSON_FLAG not in head:
|
|
57
|
+
return argv, False
|
|
58
|
+
stripped = [a for a in head if a != JSON_FLAG] + tail
|
|
59
|
+
streaming = tuple(stripped[:2]) in JSON_STREAMING_COMMANDS
|
|
60
|
+
return stripped, not streaming
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ScriptIt:
|
|
64
|
+
"""Drive your Script.it sandbox from this machine.
|
|
65
|
+
|
|
66
|
+
Connect:
|
|
67
|
+
scriptit auth login # browser sign-in, stores a token
|
|
68
|
+
scriptit auth status # who am I, which deployment
|
|
69
|
+
|
|
70
|
+
Work in the sandbox:
|
|
71
|
+
scriptit exec -- <command> # arbitrary shell
|
|
72
|
+
scriptit exec --timeout 60 -- <cmd> # ...bounded sandbox-side
|
|
73
|
+
scriptit start <script> # ...and every other verb, which
|
|
74
|
+
scriptit trigger list # the sandbox's CLI answers
|
|
75
|
+
scriptit describe --concepts # the platform reference
|
|
76
|
+
|
|
77
|
+
Move files and manage the session:
|
|
78
|
+
scriptit fs read|write|push|pull
|
|
79
|
+
scriptit context # fetch agent instructions
|
|
80
|
+
scriptit session new|use|current|list
|
|
81
|
+
scriptit sandbox status|wake
|
|
82
|
+
|
|
83
|
+
Add --json to any of this client's own commands for machine-readable
|
|
84
|
+
output: stdout becomes one JSON document, everything else goes to stderr.
|
|
85
|
+
|
|
86
|
+
Everything runs in a session that is visible in the Script.it app.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self.auth = AuthCommands()
|
|
91
|
+
self.fs = FsCommands()
|
|
92
|
+
self.session = SessionCommands()
|
|
93
|
+
self.sandbox = SandboxCommands()
|
|
94
|
+
|
|
95
|
+
def context(self) -> None:
|
|
96
|
+
"""Fetch the complete agent context for the current session."""
|
|
97
|
+
show_context()
|
|
98
|
+
|
|
99
|
+
def version(self) -> None:
|
|
100
|
+
"""Print this client's version."""
|
|
101
|
+
emit(
|
|
102
|
+
{"version": __version__},
|
|
103
|
+
lambda: print(f"scriptit-cli {__version__}"),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _route(argv: List[str]) -> int:
|
|
108
|
+
"""Run one invocation and return its exit status."""
|
|
109
|
+
if argv and argv[0] in ("--version", "-V"):
|
|
110
|
+
print(f"scriptit-cli {__version__}")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
if argv and argv[0] == "exec":
|
|
114
|
+
return remote_exec(argv[1:])
|
|
115
|
+
|
|
116
|
+
handled = remote_dispatch_if_applicable(argv)
|
|
117
|
+
if handled is not None:
|
|
118
|
+
return handled
|
|
119
|
+
|
|
120
|
+
# Fire parses sys.argv directly, so the --json it must not see is removed.
|
|
121
|
+
sys.argv = [sys.argv[0], *argv]
|
|
122
|
+
fire.Fire(ScriptIt, name="scriptit")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ensure_json_error(code: int) -> None:
|
|
127
|
+
"""Keep the ``--json`` contract on a failure that never reached `output`.
|
|
128
|
+
|
|
129
|
+
`fail()` writes ``{"error": ...}`` for everything this client rejects
|
|
130
|
+
itself, but Fire answers a bad command line by printing usage and exiting
|
|
131
|
+
on its own. Without this, `scriptit session use --json` exits 2 with an
|
|
132
|
+
empty stdout — a caller parsing stdout gets nothing to parse, which is
|
|
133
|
+
exactly what the contract exists to prevent. Fire's own message is already
|
|
134
|
+
on stderr, where a non-result belongs.
|
|
135
|
+
"""
|
|
136
|
+
if not output.json_mode() or output.emitted():
|
|
137
|
+
return
|
|
138
|
+
json.dump(
|
|
139
|
+
{"error": f"command failed (exit {code}); see stderr for details"},
|
|
140
|
+
sys.stdout,
|
|
141
|
+
indent=2,
|
|
142
|
+
)
|
|
143
|
+
sys.stdout.write("\n")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main() -> None:
|
|
147
|
+
argv, json_mode = extract_json_flag(sys.argv[1:])
|
|
148
|
+
set_json_mode(json_mode)
|
|
149
|
+
|
|
150
|
+
notice = update_check.check()
|
|
151
|
+
if notice:
|
|
152
|
+
warn(notice)
|
|
153
|
+
|
|
154
|
+
started = time.monotonic()
|
|
155
|
+
code = 0
|
|
156
|
+
try:
|
|
157
|
+
code = _route(argv)
|
|
158
|
+
if code != 0:
|
|
159
|
+
_ensure_json_error(code)
|
|
160
|
+
except SystemExit as exc:
|
|
161
|
+
# Fire exits directly on `--help` and on a usage error; that status is
|
|
162
|
+
# the invocation's outcome and belongs in the event like any other.
|
|
163
|
+
code = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
|
|
164
|
+
if code != 0:
|
|
165
|
+
_ensure_json_error(code)
|
|
166
|
+
raise
|
|
167
|
+
finally:
|
|
168
|
+
# Wrapped: a broken analytics path must not turn a successful command
|
|
169
|
+
# into a traceback, or change the status the caller sees.
|
|
170
|
+
with contextlib.suppress(Exception):
|
|
171
|
+
analytics.track(
|
|
172
|
+
"cli_command_completed",
|
|
173
|
+
command=safe_command(argv),
|
|
174
|
+
forwarded=bool(argv) and argv[0] not in CLIENT_COMMANDS,
|
|
175
|
+
exit_code=code,
|
|
176
|
+
ok=code == 0,
|
|
177
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
178
|
+
json_mode=json_mode,
|
|
179
|
+
)
|
|
180
|
+
sys.exit(code)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
scriptit_cli/output.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Human vs machine output.
|
|
2
|
+
|
|
3
|
+
``--json`` makes stdout exactly one JSON document and sends everything else —
|
|
4
|
+
progress, warnings, errors — to stderr. That contract is what lets a caller
|
|
5
|
+
pipe stdout straight into a parser without stripping chatter first, which
|
|
6
|
+
matters here because the usual caller is an agent.
|
|
7
|
+
|
|
8
|
+
Human mode is unchanged: prose on stdout, errors on stderr.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Callable, NoReturn
|
|
16
|
+
|
|
17
|
+
_json_mode = False
|
|
18
|
+
_emitted = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_json_mode(enabled: bool) -> None:
|
|
22
|
+
global _json_mode
|
|
23
|
+
_json_mode = enabled
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def json_mode() -> bool:
|
|
27
|
+
return _json_mode
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def emitted() -> bool:
|
|
31
|
+
"""Whether the one JSON document has already been written.
|
|
32
|
+
|
|
33
|
+
The entry point uses this to answer a failure that never reached this
|
|
34
|
+
module — an argument parser rejecting the command line, say — without
|
|
35
|
+
writing a second document over a real result.
|
|
36
|
+
"""
|
|
37
|
+
return _emitted
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def emit(payload: Any, human: Callable[[], None]) -> None:
|
|
41
|
+
"""The command's result: one JSON document, or whatever ``human`` prints."""
|
|
42
|
+
global _emitted
|
|
43
|
+
if _json_mode:
|
|
44
|
+
json.dump(payload, sys.stdout, indent=2)
|
|
45
|
+
sys.stdout.write("\n")
|
|
46
|
+
_emitted = True
|
|
47
|
+
else:
|
|
48
|
+
human()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def note(message: str) -> None:
|
|
52
|
+
"""Progress and advisories — never part of the result.
|
|
53
|
+
|
|
54
|
+
Flushed: callers print a URL and then block waiting for the browser, and
|
|
55
|
+
through a pipe an unflushed line would not appear until the wait ended.
|
|
56
|
+
"""
|
|
57
|
+
print(message, file=sys.stderr if _json_mode else sys.stdout, flush=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def warn(message: str) -> None:
|
|
61
|
+
"""Always stderr: a warning is not a result in either mode."""
|
|
62
|
+
print(message, file=sys.stderr, flush=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fail(message: str, code: int = 1) -> NoReturn:
|
|
66
|
+
"""Report an error and exit.
|
|
67
|
+
|
|
68
|
+
In JSON mode this is still the one document on stdout — a caller that
|
|
69
|
+
parses stdout gets a structured reason instead of an empty stream plus
|
|
70
|
+
prose it would have to read.
|
|
71
|
+
"""
|
|
72
|
+
global _emitted
|
|
73
|
+
if _json_mode:
|
|
74
|
+
json.dump({"error": message}, sys.stdout, indent=2)
|
|
75
|
+
sys.stdout.write("\n")
|
|
76
|
+
_emitted = True
|
|
77
|
+
else:
|
|
78
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
79
|
+
sys.exit(code)
|