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.
@@ -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)