skydive-cli 0.1.0-beta.353 → 0.1.0-beta.378

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.
package/README.md CHANGED
@@ -8,9 +8,45 @@ npx skydive-cli --help
8
8
 
9
9
  ## Authentication
10
10
 
11
- The CLI has two credentials, stored together in a single `config.json`. The
12
- location is platform-conventional (resolved via `env-paths`), so it is **not**
13
- `~/.config/skydive/` everywhere:
11
+ Sign in once via the browser the same shape as `doppler login`: the CLI
12
+ opens the approval page, shows a short code, waits for you to approve, and
13
+ then automatically mints an API key for this machine:
14
+
15
+ ```sh
16
+ skydive auth login
17
+ ```
18
+
19
+ That single flow yields both credentials the CLI uses, stored together in a
20
+ single `config.json`:
21
+
22
+ - an **API key** (`sky_live_…`) named after this machine and the workspace it
23
+ was minted in (`CLI (host) — Acme`) — the durable credential for the
24
+ management commands (`agents`, `keys`, `secrets`). Logging in again from the
25
+ same machine replaces it rather than accumulating rows, and
26
+ `skydive auth logout` revokes it, so signing out doesn't leave a live key
27
+ behind. The workspace is in the name because the key is pinned to it: unlike
28
+ the session, a key never follows `workspace switch`.
29
+ - a **user session** for `skydive chat`. Chat is user-level and multi-agent,
30
+ so it authenticates as you — and unlike the API key (which is pinned to the
31
+ workspace that minted it), the session follows `skydive workspace switch`.
32
+ The management commands prefer the session while it's valid and fall back
33
+ to the API key.
34
+
35
+ The flow doesn't require a TTY: in a non-interactive shell (an agent harness,
36
+ SSH without a display) `auth login` still prints the verification URL + code
37
+ and waits — open the link in any browser to approve. For unattended CI, skip
38
+ the wait entirely and pass an existing key (mint one at
39
+ `skydive.com/settings/account`):
40
+
41
+ ```sh
42
+ skydive auth login --api-key sky_live_… # or set SKYDIVE_API_KEY
43
+ ```
44
+
45
+ `skydive auth status` shows both credentials; `skydive auth logout` revokes
46
+ the auto-minted key and clears them.
47
+
48
+ The config location is platform-conventional (resolved via `env-paths`), so
49
+ it is **not** `~/.config/skydive/` everywhere:
14
50
 
15
51
  | Platform | Path |
16
52
  | -------- | ---------------------------------------------------------------- |
@@ -21,29 +57,6 @@ location is platform-conventional (resolved via `env-paths`), so it is **not**
21
57
  `skydive auth status` prints the real resolved path (`Config: …`) — trust that
22
58
  over this table if they ever disagree.
23
59
 
24
- - **API key** (`sky_live_…`). Mint one at `skydive.com/settings/account`, then:
25
-
26
- ```sh
27
- skydive auth login # paste the key
28
- ```
29
-
30
- - **User session** for `skydive chat`. Chat is user-level and multi-agent,
31
- so it signs you in via the browser (device flow) rather than a per-agent
32
- API key. `skydive chat` does this automatically on first run; you can also
33
- do it up front:
34
-
35
- ```sh
36
- skydive auth login --web # opens the browser, prompts for approval
37
- ```
38
-
39
- Either credential authenticates the management commands (`agents`, `keys`,
40
- `secrets`): the CLI prefers the API key and falls back to the `--web` chat
41
- session, so a `--web` login alone is enough to run them — you do **not** need a
42
- separate API key. The API key is only required when you have no chat session
43
- (e.g. CI). `skydive chat` requires the chat session specifically.
44
-
45
- `skydive auth status` shows both; `skydive auth logout` clears them.
46
-
47
60
  ## Commands
48
61
 
49
62
  ```sh
@@ -164,7 +177,7 @@ skydive chat -p "status?" --agent grace --json # structured envelo
164
177
  - `--conversation <id>` continues an existing thread; omitted, it starts a new
165
178
  one. The conversation id is included in `--json` output for chaining.
166
179
  - `-p` needs a user session just like `chat`, but never opens the interactive
167
- browser login — sign in first with `skydive auth login --web` or set
180
+ browser login — sign in first with `skydive auth login` or set
168
181
  `SKYDIVE_SESSION_TOKEN`.
169
182
  - `--json` prints `{ agentId, agentName, conversationId, isNewConversation,
170
183
  messageId, text }` instead of streaming the raw text.
package/dist/js/bin.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { t as __exportAll } from "./rolldown-runtime-Cz4Tg37Z.mjs";
3
3
  import { hideBin } from "yargs/helpers";
4
4
  import yargs from "yargs";
5
- import { createInterface } from "node:readline";
5
+ import os, { hostname } from "node:os";
6
6
  import path from "node:path";
7
7
  import Conf from "conf";
8
8
  import { err, ok } from "neverthrow";
@@ -13,10 +13,10 @@ import { spawnSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
14
  import fs from "node:fs";
15
15
  import zlib from "node:zlib";
16
- import os from "node:os";
16
+ import { WebSocket } from "ws";
17
17
 
18
18
  //#region package.json
19
- var version$1 = "0.1.0-beta.353";
19
+ var version$1 = "0.1.0-beta.378";
20
20
 
21
21
  //#endregion
22
22
  //#region src/types.ts
@@ -56,10 +56,18 @@ const DEFAULT_WEB_URL = "https://skydive.com";
56
56
  function resolveWebUrl(appUrl) {
57
57
  return appUrl === DEFAULT_APP_URL ? DEFAULT_WEB_URL : appUrl;
58
58
  }
59
- /** Prefix on every Skydive API key. Kept in sync with the API's
59
+ /** Prefix on workspace-scoped Skydive API keys. Kept in sync with the API's
60
60
  * `API_KEY_PREFIX` (`apps/anyone/api/src/lib/api-key.ts`); the CLI is a
61
61
  * standalone published package so it can't import the backend constant. */
62
62
  const API_KEY_PREFIX = "sky_live_";
63
+ /**
64
+ * Common prefix across all Skydive API key kinds — `sky_live_…` workspace
65
+ * keys today, `sky_user_…` account keys when ANY-5105 lands. The CLI only
66
+ * sanity-checks the family on `--api-key`; the server authoritatively rejects
67
+ * a kind that can't drive a given route, with a clearer message than the
68
+ * client could produce.
69
+ */
70
+ const API_KEY_FAMILY_PREFIX = "sky_";
63
71
  /** Where users mint and copy API keys. Shown in the login prompt. */
64
72
  const API_KEYS_URL = "skydive.com/settings/account";
65
73
  const store = new Conf({
@@ -78,9 +86,9 @@ function resolveConfig(opts) {
78
86
  }
79
87
  /**
80
88
  * Resolve the bearer credential for the management API (`agents` / `keys` /
81
- * `secrets`). The server's `/v1` gate accepts either an API key or the `--web`
82
- * session bearer, so both work — but only one of them tracks the active
83
- * workspace.
89
+ * `secrets`). The server's `/v1` gate accepts either an API key or the
90
+ * device-flow session bearer, so both work — but only one of them tracks the
91
+ * active workspace.
84
92
  *
85
93
  * An API key is pinned server-side to the organization that minted it and
86
94
  * ignores the workspace header by design, so it can never follow `skydive
@@ -104,11 +112,17 @@ function resolveManagementAuth(opts) {
104
112
  apiUrl: process.env["SKYDIVE_API_URL"] ?? opts.apiUrl ?? store.get("apiUrl") ?? DEFAULT_API_URL,
105
113
  kind: "api-key"
106
114
  });
107
- return err({ message: "Not authenticated. Run `skydive auth login` (API key) or `skydive auth login --web`." });
115
+ return err({ message: "Not authenticated. Run `skydive auth login`." });
108
116
  }
109
117
  function saveConfig(config) {
110
118
  store.set("apiKey", config.apiKey);
111
119
  store.set("apiUrl", config.apiUrl);
120
+ if (config.apiKeyId) store.set("apiKeyId", config.apiKeyId);
121
+ else store.delete("apiKeyId");
122
+ }
123
+ /** Server-side id of the auto-minted key, if login minted one. */
124
+ function getStoredApiKeyId() {
125
+ return store.get("apiKeyId") ?? null;
112
126
  }
113
127
  function deleteConfig() {
114
128
  store.clear();
@@ -320,7 +334,7 @@ var SkydiveApiClient = class {
320
334
  if (body.error && typeof body.error === "string") message = body.error;
321
335
  else if (body.error?.message) message = body.error.message;
322
336
  } catch {}
323
- if (response.status === 401 && this.authKind === "session") message = `${message} — your chat session may have expired. Run \`skydive auth login --web\`.`;
337
+ if (response.status === 401 && this.authKind === "session") message = `${message} — your chat session may have expired. Run \`skydive auth login\`.`;
324
338
  return err({
325
339
  message,
326
340
  status: response.status
@@ -647,6 +661,94 @@ function sleep$1(ms) {
647
661
  return new Promise((resolve) => setTimeout(resolve, ms));
648
662
  }
649
663
 
664
+ //#endregion
665
+ //#region src/auth/mint-key.ts
666
+ const mintedKeySchema = z.object({
667
+ id: z.string(),
668
+ name: z.string(),
669
+ prefix: z.string(),
670
+ key: z.string()
671
+ });
672
+ /**
673
+ * Mint an org-scoped (account-level) API key off a fresh device-flow session,
674
+ * so `auth login` ends with a durable machine credential the way
675
+ * `doppler login` does. Calls the same internal endpoint the web settings page
676
+ * uses; no `agentId` is sent, so the key is account-level, not agent-bound.
677
+ *
678
+ * Scopes are `use` + `edit` because the management commands the key powers
679
+ * (`agents create`, `keys create`, `secrets set`) are edit-tier operations.
680
+ */
681
+ async function mintCliApiKey({ appUrl, sessionToken, name }) {
682
+ try {
683
+ const res = await fetch(`${appUrl}/api/v1/api-keys`, {
684
+ method: "POST",
685
+ headers: {
686
+ authorization: `Bearer ${sessionToken}`,
687
+ "content-type": "application/json"
688
+ },
689
+ body: JSON.stringify({
690
+ name,
691
+ scopes: ["use", "edit"]
692
+ })
693
+ });
694
+ if (!res.ok) return err({ message: `failed to mint API key (${res.status})` });
695
+ const parsed = mintedKeySchema.safeParse(await res.json());
696
+ if (!parsed.success) return err({ message: "unexpected mint response" });
697
+ return ok(parsed.data);
698
+ } catch (e) {
699
+ return err({ message: e instanceof Error ? e.message : String(e) });
700
+ }
701
+ }
702
+ const keyListSchema = z.object({ keys: z.array(z.object({
703
+ id: z.string(),
704
+ name: z.string(),
705
+ agentId: z.string().nullish()
706
+ })) });
707
+ /**
708
+ * Revoke every active account-level key with the given name. Login calls this
709
+ * before minting so repeated logins from the same machine + workspace replace
710
+ * their key instead of piling up indistinguishable rows (`logout`'s revoke is
711
+ * best-effort, and login-without-logout is the normal case — a wiped
712
+ * container, an expired session). Agent-scoped keys are never touched even on
713
+ * a name collision.
714
+ */
715
+ async function revokeKeysByName({ appUrl, sessionToken, name }) {
716
+ try {
717
+ const res = await fetch(`${appUrl}/api/v1/api-keys`, { headers: { authorization: `Bearer ${sessionToken}` } });
718
+ if (!res.ok) return err({ message: `failed to list API keys (${res.status})` });
719
+ const parsed = keyListSchema.safeParse(await res.json());
720
+ if (!parsed.success) return err({ message: "unexpected key list response" });
721
+ const matches = parsed.data.keys.filter((key) => key.name === name && !key.agentId);
722
+ const failures = (await Promise.all(matches.map((key) => revokeApiKey({
723
+ appUrl,
724
+ token: sessionToken,
725
+ id: key.id
726
+ })))).filter((result) => result.isErr());
727
+ if (failures.length > 0) return err({ message: `failed to revoke ${failures.length} of ${matches.length} existing keys` });
728
+ return ok(matches.length);
729
+ } catch (e) {
730
+ return err({ message: e instanceof Error ? e.message : String(e) });
731
+ }
732
+ }
733
+ /**
734
+ * Revoke an API key by id. Used by `auth logout` to clean up the key that
735
+ * login auto-minted, so signing out on a machine doesn't leave a live
736
+ * credential behind. `token` may be a session bearer or an API key — the
737
+ * server accepts both on this route.
738
+ */
739
+ async function revokeApiKey({ appUrl, token, id }) {
740
+ try {
741
+ const res = await fetch(`${appUrl}/api/v1/api-keys/${id}`, {
742
+ method: "DELETE",
743
+ headers: { authorization: `Bearer ${token}` }
744
+ });
745
+ if (!res.ok && res.status !== 404) return err({ message: `failed to revoke API key (${res.status})` });
746
+ return ok(void 0);
747
+ } catch (e) {
748
+ return err({ message: e instanceof Error ? e.message : String(e) });
749
+ }
750
+ }
751
+
650
752
  //#endregion
651
753
  //#region src/output.ts
652
754
  function output(argv, data) {
@@ -676,99 +778,160 @@ function printError(message) {
676
778
  //#region src/commands/auth.ts
677
779
  const loginCommand = {
678
780
  command: "login",
679
- describe: "Authenticate with a Skydive API key (or --web for chat)",
781
+ describe: "Sign in via the browser (use --api-key for CI / headless)",
680
782
  builder: (y) => y.option("api-key", {
681
783
  type: "string",
682
- describe: `API key (${API_KEY_PREFIX}...)`
784
+ describe: `Skip the browser and authenticate with an existing API key (${API_KEY_PREFIX}...)`
683
785
  }).option("web", {
684
786
  type: "boolean",
685
787
  default: false,
686
- describe: "Sign in for `skydive chat` via the browser (device flow) instead of an API key"
788
+ hidden: true,
789
+ describe: "Deprecated: browser sign-in is now the default"
687
790
  }),
688
791
  handler: async (argv) => {
689
- if (argv.web) {
690
- await runWebLogin(argv);
792
+ if (argv["api-key"]) {
793
+ await runApiKeyLogin(argv, argv["api-key"]);
691
794
  return;
692
795
  }
693
- let apiKey = argv["api-key"] ?? process.env["SKYDIVE_API_KEY"];
694
- if (!apiKey) {
695
- const rl = createInterface({
696
- input: process.stdin,
697
- output: process.stderr
698
- });
699
- apiKey = await new Promise((resolve) => {
700
- rl.question(`Enter your API key (from ${API_KEYS_URL}): `, (answer) => {
701
- rl.close();
702
- resolve(answer.trim());
703
- });
704
- });
705
- }
706
- if (!apiKey) {
707
- printError("No API key provided.");
708
- process.exit(1);
709
- }
710
- if (!apiKey.startsWith(API_KEY_PREFIX)) {
711
- printError(`API key must start with ${API_KEY_PREFIX}`);
712
- process.exit(1);
713
- }
714
- const apiUrl = argv["api-url"] ?? DEFAULT_API_URL;
715
- const result = await new SkydiveApiClient({
716
- token: apiKey,
717
- apiUrl,
718
- kind: "api-key"
719
- }).listAgents({
720
- limit: 1,
721
- scope: null
722
- });
723
- if (result.isErr()) {
724
- printError(`Invalid API key or unreachable server: ${result.error.message}`);
725
- process.exit(1);
796
+ if (isNonInteractive()) {
797
+ const envKey = process.env["SKYDIVE_API_KEY"];
798
+ if (envKey) {
799
+ await runApiKeyLogin(argv, envKey);
800
+ return;
801
+ }
726
802
  }
727
- saveConfig({
728
- apiKey,
729
- apiUrl
730
- });
731
- if (argv.json) output(argv, {
803
+ await runBrowserLogin(argv);
804
+ }
805
+ };
806
+ /**
807
+ * Browser sign-in, shaped like `doppler login`: open the approval page with a
808
+ * short code, wait for the user to approve, then automatically mint an API
809
+ * key for this machine. One flow yields both credentials — the session (chat,
810
+ * workspace-following management calls) and a durable `sky_live_…` key that
811
+ * keeps headless/management use working after the session expires.
812
+ *
813
+ * Deliberately no TTY requirement: the flow only prints the verification URL
814
+ * + code and polls, so it also works from a non-interactive shell — an agent
815
+ * driving the CLI relays the URL to its human, who approves in any browser
816
+ * (the auto-open is best-effort). The cost is that an unattended run with no
817
+ * key configured waits out the device code's expiry instead of failing fast.
818
+ */
819
+ async function runBrowserLogin(argv) {
820
+ const appUrl = resolveAppUrl({ appUrl: argv["api-url"] });
821
+ const login = await loginWithDevice({ appUrl });
822
+ if (login.isErr()) {
823
+ printError(login.error.message);
824
+ process.exit(1);
825
+ }
826
+ const { sessionToken } = login.value;
827
+ const identity = (await getSessionIdentity({
828
+ appUrl,
829
+ sessionToken
830
+ })).unwrapOr(null);
831
+ const keyName = identity?.activeWorkspaceName ? `CLI (${hostname()}) — ${identity.activeWorkspaceName}` : `CLI (${hostname()})`;
832
+ const replaced = await revokeKeysByName({
833
+ appUrl,
834
+ sessionToken,
835
+ name: keyName
836
+ });
837
+ if (replaced.isErr()) process.stderr.write(`Warning: could not revoke this machine's previous API key (${replaced.error.message}); minting a new one anyway.\n`);
838
+ const minted = await mintCliApiKey({
839
+ appUrl,
840
+ sessionToken,
841
+ name: keyName
842
+ });
843
+ if (minted.isOk()) saveConfig({
844
+ apiKey: minted.value.key,
845
+ apiUrl: appUrl,
846
+ apiKeyId: minted.value.id
847
+ });
848
+ else process.stderr.write(`Warning: signed in, but could not mint an API key (${minted.error.message}). Management commands will use your session; run \`skydive auth login\` again to retry.
849
+ `);
850
+ if (argv.json) {
851
+ output(argv, {
732
852
  authenticated: true,
733
- prefix: apiKey.slice(0, 12),
853
+ mode: "browser",
854
+ appUrl,
855
+ prefix: minted.isOk() ? minted.value.prefix : null,
856
+ email: identity?.email ?? null,
857
+ workspaceName: identity?.activeWorkspaceName ?? null,
734
858
  configPath: getConfigPath()
735
859
  });
736
- else if (!argv.quiet) {
737
- console.log(`Authenticated successfully.`);
738
- console.log(` Key: ${apiKey.slice(0, 12)}...`);
739
- console.log(` Config: ${getConfigPath()}`);
740
- }
860
+ return;
741
861
  }
742
- };
743
- async function runWebLogin(argv) {
744
- if (isNonInteractive()) {
745
- printError("`auth login --web` needs an interactive terminal. Set SKYDIVE_SESSION_TOKEN for non-interactive use.");
862
+ if (argv.quiet) return;
863
+ const who = identity?.name ?? identity?.email;
864
+ console.log(who ? `Welcome, ${who}!` : "Signed in.");
865
+ if (identity?.activeWorkspaceName) console.log(` Workspace: ${identity.activeWorkspaceName}`);
866
+ if (minted.isOk()) console.log(` API key: ${minted.value.prefix}… (${minted.value.name})`);
867
+ console.log(` Config: ${getConfigPath()}`);
868
+ }
869
+ /** CI / headless path: validate and persist an existing key, no browser. */
870
+ async function runApiKeyLogin(argv, apiKey) {
871
+ if (!apiKey.startsWith(API_KEY_FAMILY_PREFIX)) {
872
+ printError(`API key must start with ${API_KEY_FAMILY_PREFIX}`);
746
873
  process.exit(1);
747
874
  }
748
- const result = await loginWithDevice({ appUrl: resolveAppUrl({ appUrl: argv["api-url"] }) });
875
+ const apiUrl = argv["api-url"] ?? DEFAULT_API_URL;
876
+ const result = await new SkydiveApiClient({
877
+ token: apiKey,
878
+ apiUrl,
879
+ kind: "api-key"
880
+ }).listAgents({
881
+ limit: 1,
882
+ scope: null
883
+ });
749
884
  if (result.isErr()) {
750
- printError(result.error.message);
885
+ printError(`Invalid API key or unreachable server: ${result.error.message}`);
751
886
  process.exit(1);
752
887
  }
888
+ saveConfig({
889
+ apiKey,
890
+ apiUrl,
891
+ apiKeyId: null
892
+ });
753
893
  if (argv.json) output(argv, {
754
894
  authenticated: true,
755
- mode: "session",
756
- appUrl: result.value.appUrl,
895
+ prefix: apiKey.slice(0, 12),
757
896
  configPath: getConfigPath()
758
897
  });
759
898
  else if (!argv.quiet) {
760
- console.log("Signed in for chat.");
761
- console.log(` App: ${result.value.appUrl}`);
899
+ console.log(`Authenticated successfully.`);
900
+ console.log(` Key: ${apiKey.slice(0, 12)}...`);
762
901
  console.log(` Config: ${getConfigPath()}`);
763
902
  }
764
903
  }
765
904
  const logoutCommand = {
766
905
  command: "logout",
767
- describe: "Clear stored credentials (API key and chat session)",
906
+ describe: "Revoke the auto-minted API key and clear stored credentials",
768
907
  handler: async (argv) => {
908
+ const keyId = getStoredApiKeyId();
909
+ let keyRevoked = false;
910
+ if (keyId) {
911
+ const session = resolveSession({ appUrl: argv["api-url"] });
912
+ const config = resolveConfig({ apiUrl: argv["api-url"] });
913
+ const credential = session.isOk() ? {
914
+ appUrl: session.value.appUrl,
915
+ token: session.value.sessionToken
916
+ } : config.isOk() ? {
917
+ appUrl: config.value.apiUrl,
918
+ token: config.value.apiKey
919
+ } : null;
920
+ if (credential) {
921
+ const revoked = await revokeApiKey({
922
+ ...credential,
923
+ id: keyId
924
+ });
925
+ keyRevoked = revoked.isOk();
926
+ if (revoked.isErr()) process.stderr.write(`Warning: could not revoke this machine's API key (${revoked.error.message}). Revoke it manually at ${API_KEYS_URL}.\n`);
927
+ }
928
+ }
769
929
  deleteConfig();
770
- if (argv.json) output(argv, { authenticated: false });
771
- else if (!argv.quiet) console.log("Logged out.");
930
+ if (argv.json) output(argv, {
931
+ authenticated: false,
932
+ keyRevoked
933
+ });
934
+ else if (!argv.quiet) console.log(keyRevoked ? "Logged out. API key revoked." : "Logged out.");
772
935
  }
773
936
  };
774
937
  const statusCommand$1 = {
@@ -801,7 +964,7 @@ const statusCommand$1 = {
801
964
  return;
802
965
  }
803
966
  if (apiKey.isErr() && session.isErr()) {
804
- console.log("Not authenticated. Run `skydive auth login` (API key) or `skydive auth login --web` (chat).");
967
+ console.log("Not authenticated. Run `skydive auth login`.");
805
968
  return;
806
969
  }
807
970
  if (apiKey.isOk()) {
@@ -838,6 +1001,10 @@ const authCommand = {
838
1001
 
839
1002
  //#endregion
840
1003
  //#region src/chat/api/rest.ts
1004
+ var rest_exports = /* @__PURE__ */ __exportAll({
1005
+ HttpError: () => HttpError,
1006
+ createRestClient: () => createRestClient
1007
+ });
841
1008
  var HttpError = class extends Error {
842
1009
  constructor(status, body) {
843
1010
  super(`HTTP ${status}: ${body.slice(0, 200)}`);
@@ -1257,7 +1424,7 @@ const conversationStreamEventSchema = z.discriminatedUnion("kind", [z.object({
1257
1424
  function requireSession(argv) {
1258
1425
  const session = resolveSession({ appUrl: resolveAppUrl({ appUrl: argv["api-url"] }) });
1259
1426
  if (session.isErr()) {
1260
- printError(`${session.error.message} Run \`skydive auth login --web\` first, or set SKYDIVE_SESSION_TOKEN.`);
1427
+ printError(`${session.error.message} Run \`skydive auth login\` first, or set SKYDIVE_SESSION_TOKEN.`);
1261
1428
  process.exit(1);
1262
1429
  }
1263
1430
  return session.value;
@@ -2592,7 +2759,7 @@ const chatCommand = {
2592
2759
  let session = resolveSession({ appUrl });
2593
2760
  if (session.isErr()) {
2594
2761
  if (isNonInteractive()) {
2595
- printError("Not signed in for chat and no interactive terminal. Run `skydive auth login --web`, or set SKYDIVE_SESSION_TOKEN.");
2762
+ printError("Not signed in for chat and no interactive terminal. Run `skydive auth login`, or set SKYDIVE_SESSION_TOKEN.");
2596
2763
  process.exit(1);
2597
2764
  }
2598
2765
  const login = await loginWithDevice({ appUrl });
@@ -2611,7 +2778,7 @@ const chatCommand = {
2611
2778
  printError(`Unknown theme "${themeId}". Valid themes: ${themes.map((t) => t.id).join(", ")}`);
2612
2779
  process.exit(1);
2613
2780
  }
2614
- const { runChat } = await import("./boot-DLHvjtxn.mjs");
2781
+ const { runChat } = await import("./boot-QnNiy1cA.mjs");
2615
2782
  await runChat({
2616
2783
  appUrl,
2617
2784
  sessionToken: session.value.sessionToken,
@@ -2635,7 +2802,7 @@ function resolveShareMachine(argv) {
2635
2802
  async function runPrintMode({ argv, appUrl }) {
2636
2803
  const session = resolveSession({ appUrl });
2637
2804
  if (session.isErr()) {
2638
- printError(`${session.error.message} For non-interactive use, run \`skydive auth login --web\` first, or set SKYDIVE_SESSION_TOKEN.`);
2805
+ printError(`${session.error.message} For non-interactive use, run \`skydive auth login\` first, or set SKYDIVE_SESSION_TOKEN.`);
2639
2806
  process.exit(1);
2640
2807
  }
2641
2808
  const { runPrint, readStdin } = await Promise.resolve().then(() => print_exports);
@@ -2653,7 +2820,7 @@ async function runPrintMode({ argv, appUrl }) {
2653
2820
  }
2654
2821
  let machineShare = null;
2655
2822
  if (resolveShareMachine(argv)) {
2656
- const { PortalClient } = await import("./client-_OL8-XGH.mjs").then((n) => n.n);
2823
+ const { PortalClient } = await import("./client-XFsd0Wy9.mjs").then((n) => n.n);
2657
2824
  let signalConnected;
2658
2825
  const connected = new Promise((resolve) => {
2659
2826
  signalConnected = resolve;
@@ -2723,7 +2890,7 @@ const getCommand = {
2723
2890
  const appUrl = resolveAppUrl({ appUrl: argv["api-url"] });
2724
2891
  const session = resolveSession({ appUrl });
2725
2892
  if (session.isErr()) {
2726
- printError(`${session.error.message} Run \`skydive auth login --web\` first, or set SKYDIVE_SESSION_TOKEN.`);
2893
+ printError(`${session.error.message} Run \`skydive auth login\` first, or set SKYDIVE_SESSION_TOKEN.`);
2727
2894
  process.exit(1);
2728
2895
  }
2729
2896
  const { messageGet } = await Promise.resolve().then(() => print_exports);
@@ -3405,7 +3572,7 @@ const switchCommand = {
3405
3572
  printError("The workspace picker needs the Bun runtime and it could not be set up automatically. Pass a workspace slug instead, or install Bun and retry.");
3406
3573
  process.exit(1);
3407
3574
  }
3408
- const { runWorkspacePicker } = await import("./boot-DLHvjtxn.mjs");
3575
+ const { runWorkspacePicker } = await import("./boot-QnNiy1cA.mjs");
3409
3576
  await runWorkspacePicker(session);
3410
3577
  return;
3411
3578
  }
@@ -3622,7 +3789,7 @@ const openCommand = {
3622
3789
  const agent = argv.agent ? resolveAgent((await fetchPortalDevices(session)).agents, argv.agent) : null;
3623
3790
  const cwd = argv.cwd ? path.resolve(argv.cwd) : process.cwd();
3624
3791
  const { machineName } = machineIdentity();
3625
- const { PortalClient } = await import("./client-_OL8-XGH.mjs").then((n) => n.n);
3792
+ const { PortalClient } = await import("./client-XFsd0Wy9.mjs").then((n) => n.n);
3626
3793
  let lastLine = "";
3627
3794
  let signalConnected;
3628
3795
  const connected = new Promise((resolve) => {
@@ -3753,10 +3920,298 @@ const portalCommand = {
3753
3920
  handler: () => {}
3754
3921
  };
3755
3922
 
3923
+ //#endregion
3924
+ //#region ../sandbox-stream-protocol/src/index.ts
3925
+ const SANDBOX_STREAM_PATH = "/api/v1/sandbox/stream";
3926
+ const FRAME = {
3927
+ DATA: 1,
3928
+ EXIT: 2,
3929
+ ERROR: 3,
3930
+ INPUT: 16,
3931
+ RESIZE: 17
3932
+ };
3933
+ const MAX_INPUT_BYTES = 1 * 1024 * 1024;
3934
+ /** Query params for the upgrade URL, from a spec. Inverse of {@link parseStreamSpec}. */
3935
+ function streamSpecToQuery(spec) {
3936
+ if (spec.mode === "pty") return {
3937
+ agentId: spec.agentId,
3938
+ mode: "pty",
3939
+ cols: String(spec.cols),
3940
+ rows: String(spec.rows)
3941
+ };
3942
+ return {
3943
+ agentId: spec.agentId,
3944
+ mode: "exec",
3945
+ command: spec.command
3946
+ };
3947
+ }
3948
+ function withType(type, payload) {
3949
+ const frame = new Uint8Array(1 + payload.length);
3950
+ frame[0] = type;
3951
+ frame.set(payload, 1);
3952
+ return frame;
3953
+ }
3954
+ /** client → server: keystroke bytes for the pty stdin. */
3955
+ function encodeInput(data) {
3956
+ return withType(FRAME.INPUT, data);
3957
+ }
3958
+ /** client → server: the client terminal was resized. */
3959
+ function encodeResize(cols, rows) {
3960
+ const frame = new Uint8Array(5);
3961
+ frame[0] = FRAME.RESIZE;
3962
+ const view = new DataView(frame.buffer);
3963
+ view.setUint16(1, cols & 65535);
3964
+ view.setUint16(3, rows & 65535);
3965
+ return frame;
3966
+ }
3967
+ const view = (frame) => new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
3968
+ /**
3969
+ * Decode a frame the server sent. Returns null for an empty, unknown, or
3970
+ * truncated frame — a peer speaking a newer protocol must not crash us.
3971
+ */
3972
+ function decodeServerFrame(frame) {
3973
+ const payload = frame.subarray(1);
3974
+ switch (frame[0]) {
3975
+ case FRAME.DATA: return {
3976
+ type: "data",
3977
+ payload
3978
+ };
3979
+ case FRAME.EXIT: return {
3980
+ type: "exit",
3981
+ code: payload.length >= 4 ? view(frame).getInt32(1) : 0
3982
+ };
3983
+ case FRAME.ERROR: return {
3984
+ type: "error",
3985
+ message: new TextDecoder().decode(payload)
3986
+ };
3987
+ default: return null;
3988
+ }
3989
+ }
3990
+
3991
+ //#endregion
3992
+ //#region src/chat/sandbox/client.ts
3993
+ function wsBase(appUrl) {
3994
+ const base = appUrl.replace(/\/+$/, "");
3995
+ if (base.startsWith("https://")) return `wss://${base.slice(8)}`;
3996
+ if (base.startsWith("http://")) return `ws://${base.slice(7)}`;
3997
+ return `wss://${base}`;
3998
+ }
3999
+ /**
4000
+ * A connected sandbox-stream session. Construct via `openSandboxStream`. Carries
4001
+ * the write side (keystrokes / resize for pty mode) and teardown.
4002
+ */
4003
+ var SandboxStream = class SandboxStream {
4004
+ ws;
4005
+ closed = false;
4006
+ constructor(ws, onEvent) {
4007
+ this.ws = ws;
4008
+ let ended = false;
4009
+ const emitEnd = (event) => {
4010
+ if (ended) return;
4011
+ ended = true;
4012
+ onEvent(event);
4013
+ };
4014
+ ws.on("message", (data, isBinary) => {
4015
+ if (!isBinary) return;
4016
+ const frame = decodeServerFrame(toBuffer(data));
4017
+ if (!frame) return;
4018
+ switch (frame.type) {
4019
+ case "data":
4020
+ onEvent({
4021
+ type: "data",
4022
+ bytes: new Uint8Array(frame.payload)
4023
+ });
4024
+ break;
4025
+ case "exit":
4026
+ emitEnd({
4027
+ type: "exit",
4028
+ code: frame.code
4029
+ });
4030
+ break;
4031
+ case "error":
4032
+ emitEnd({
4033
+ type: "error",
4034
+ message: frame.message
4035
+ });
4036
+ break;
4037
+ }
4038
+ });
4039
+ let failure = null;
4040
+ ws.on("error", (err) => {
4041
+ failure = err.message;
4042
+ });
4043
+ ws.on("close", () => {
4044
+ this.closed = true;
4045
+ emitEnd({
4046
+ type: "close",
4047
+ failure
4048
+ });
4049
+ });
4050
+ }
4051
+ /** Feed keystroke bytes to the pty stdin. */
4052
+ sendInput(data) {
4053
+ if (this.closed || this.ws.readyState !== WebSocket.OPEN) return;
4054
+ this.ws.send(encodeInput(data));
4055
+ }
4056
+ /** Notify the pty of a terminal resize. */
4057
+ resize(cols, rows) {
4058
+ if (this.closed || this.ws.readyState !== WebSocket.OPEN) return;
4059
+ this.ws.send(encodeResize(cols, rows));
4060
+ }
4061
+ close() {
4062
+ this.closed = true;
4063
+ this.ws.close();
4064
+ }
4065
+ /** Open a stream. `mode` is 'pty' (interactive) or 'exec' (one-shot). */
4066
+ static open(opts) {
4067
+ const spec = opts.mode === "pty" ? {
4068
+ mode: "pty",
4069
+ agentId: opts.agentId,
4070
+ cols: opts.cols,
4071
+ rows: opts.rows
4072
+ } : {
4073
+ mode: "exec",
4074
+ agentId: opts.agentId,
4075
+ command: opts.command
4076
+ };
4077
+ const url = new URL(`${wsBase(opts.appUrl)}${SANDBOX_STREAM_PATH}`);
4078
+ for (const [key, value] of Object.entries(streamSpecToQuery(spec))) url.searchParams.set(key, value);
4079
+ return new SandboxStream(new WebSocket(url.toString(), { headers: { authorization: `Bearer ${opts.sessionToken}` } }), opts.onEvent);
4080
+ }
4081
+ };
4082
+ function toBuffer(data) {
4083
+ if (Buffer.isBuffer(data)) return data;
4084
+ if (Array.isArray(data)) return Buffer.concat(data);
4085
+ return Buffer.from(data);
4086
+ }
4087
+
4088
+ //#endregion
4089
+ //#region src/commands/sandbox.ts
4090
+ /** POSIX single-quote one word so the remote shell treats it as one token. */
4091
+ function shellQuote(word) {
4092
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(word)) return word;
4093
+ return `'${word.replace(/'/g, `'\\''`)}'`;
4094
+ }
4095
+ /**
4096
+ * Serialize the command words into the single shell string the relay runs.
4097
+ *
4098
+ * Words arrive already split by the caller's shell, so each must be re-quoted
4099
+ * or the remote shell re-splits any word containing spaces or metacharacters:
4100
+ * `sandbox -- sh -c 'echo hi; whoami'` would otherwise run `sh -c echo hi`
4101
+ * and then, separately, `whoami`. A pipeline still works the explicit way —
4102
+ * `sandbox -- sh -c 'ls | wc -l'` — which is what `docker`/`kubectl exec` ask
4103
+ * for too. yargs number-coerces bare numerals, so words are stringified.
4104
+ */
4105
+ function joinCommandWords(words) {
4106
+ return words.map(String).map(shellQuote).join(" ").trim();
4107
+ }
4108
+ /**
4109
+ * `skydive sandbox` — the standalone counterpart to the chat TUI's `/sandbox`
4110
+ * composer command (ANY-4928): a live terminal (or one-shot exec) in the
4111
+ * agent's own sandbox without opening the TUI. Runs under Node (no Bun/
4112
+ * OpenTUI): the PTY is a raw byte passthrough on the caller's real terminal.
4113
+ * The relay gates on EDIT access to the agent and the sandbox-terminal-enabled
4114
+ * kill switch, and boots the sandbox when it isn't running.
4115
+ */
4116
+ const sandboxCommand = {
4117
+ command: "sandbox [command..]",
4118
+ describe: "Open a live terminal in an agent's sandbox, or run a one-shot command there",
4119
+ builder: (y) => y.option("agent", {
4120
+ type: "string",
4121
+ describe: "Target agent, by id, slug, or name. Optional when the account has exactly one agent."
4122
+ }).positional("command", {
4123
+ type: "string",
4124
+ array: true,
4125
+ describe: "Command to run one-shot (streams output, exits with its exit code). Omit for a live interactive terminal. Put it after `--` if it has flags of its own."
4126
+ }).example("skydive sandbox --agent grace", "Live terminal (Ctrl-] detaches)").example("skydive sandbox --agent grace -- tail -n 50 /tmp/harness.log", "One-shot command (use `--` so its flags reach the sandbox)").example("skydive sandbox --agent grace -- sh -c 'ls /tmp | wc -l'", "Shell features go through an explicit `sh -c`"),
4127
+ handler: async (argv) => {
4128
+ const session = requireSession(argv);
4129
+ const { createRestClient } = await Promise.resolve().then(() => rest_exports);
4130
+ const { resolveAgent } = await Promise.resolve().then(() => print_exports);
4131
+ const client = createRestClient({
4132
+ appUrl: session.appUrl,
4133
+ sessionToken: session.sessionToken
4134
+ });
4135
+ let agent;
4136
+ try {
4137
+ agent = resolveAgent(await client.listAgents({
4138
+ scope: "org",
4139
+ onPage: null
4140
+ }), argv.agent ?? null);
4141
+ } catch (error) {
4142
+ printError(error instanceof Error ? error.message : String(error));
4143
+ process.exit(1);
4144
+ }
4145
+ const command = joinCommandWords([...argv.command ?? [], ...argv["--"] ?? []]);
4146
+ const code = command ? await runExec({
4147
+ session,
4148
+ agentId: agent.id,
4149
+ command
4150
+ }) : await runPty({
4151
+ session,
4152
+ agentId: agent.id,
4153
+ agentName: agent.name
4154
+ });
4155
+ process.exit(code);
4156
+ }
4157
+ };
4158
+ /** One-shot exec: stream output to stdout, resolve to the command's exit code. */
4159
+ function runExec({ session, agentId, command }) {
4160
+ console.error("Connecting to the sandbox…");
4161
+ return new Promise((resolve) => {
4162
+ const finish = (code) => {
4163
+ process.stdout.write("", () => resolve(code));
4164
+ };
4165
+ SandboxStream.open({
4166
+ mode: "exec",
4167
+ appUrl: session.appUrl,
4168
+ sessionToken: session.sessionToken,
4169
+ agentId,
4170
+ command,
4171
+ onEvent: (e) => {
4172
+ switch (e.type) {
4173
+ case "data":
4174
+ process.stdout.write(e.bytes);
4175
+ break;
4176
+ case "exit":
4177
+ finish(e.code);
4178
+ break;
4179
+ case "error":
4180
+ printError(e.message);
4181
+ finish(1);
4182
+ break;
4183
+ case "close":
4184
+ printError(e.failure ? `Could not run the command in the sandbox: ${e.failure}` : "The connection to the sandbox closed before the command finished.");
4185
+ finish(1);
4186
+ break;
4187
+ }
4188
+ }
4189
+ });
4190
+ });
4191
+ }
4192
+ /** Live terminal on the caller's real TTY. */
4193
+ async function runPty({ session, agentId, agentName }) {
4194
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
4195
+ printError("A live sandbox terminal needs an interactive TTY. For scripted use, pass a command: `skydive sandbox -- <cmd>`.");
4196
+ return 1;
4197
+ }
4198
+ console.error(`Connecting to ${agentName}'s sandbox… (Ctrl-] detaches)`);
4199
+ const { runRawPtyPassthrough } = await import("./raw-pty-C1DXKms6.mjs").then((n) => n.t);
4200
+ const result = await runRawPtyPassthrough({
4201
+ stdin: process.stdin,
4202
+ stdout: process.stdout,
4203
+ appUrl: session.appUrl,
4204
+ sessionToken: session.sessionToken,
4205
+ agentId
4206
+ });
4207
+ if (result.reason === "detach") console.error("\nDetached.");
4208
+ return result.code;
4209
+ }
4210
+
3756
4211
  //#endregion
3757
4212
  //#region src/cli.ts
3758
4213
  function createCli(argv) {
3759
- return yargs(argv).scriptName("skydive").usage("$0 <command> [options]").example("skydive auth login", "Store your API key").example("skydive chat", "Open the interactive chat TUI (prod)").example("skydive chat --api-url http://localhost:4500", "Chat against a local dev API").example("skydive agents list", "List your agents").example("skydive keys create \"my-key\"", "Create an API key").example("skydive secrets set OPENAI_API_KEY --agent-id <id>", "Set a secret (value from stdin)").example("skydive workspace list", "List workspaces on your account").example("skydive workspace switch acme-team", "Switch the workspace all `skydive` commands act on").example("skydive portal open --agent grace", "Open the portal to this machine for an agent, headless (no TUI)").option("json", {
4214
+ return yargs(argv).scriptName("skydive").usage("$0 <command> [options]").parserConfiguration({ "populate--": true }).example("skydive auth login", "Store your API key").example("skydive chat", "Open the interactive chat TUI (prod)").example("skydive chat --api-url http://localhost:4500", "Chat against a local dev API").example("skydive agents list", "List your agents").example("skydive keys create \"my-key\"", "Create an API key").example("skydive secrets set OPENAI_API_KEY --agent-id <id>", "Set a secret (value from stdin)").example("skydive workspace list", "List workspaces on your account").example("skydive workspace switch acme-team", "Switch the workspace all `skydive` commands act on").example("skydive portal open --agent grace", "Open the portal to this machine for an agent, headless (no TUI)").option("json", {
3760
4215
  type: "boolean",
3761
4216
  default: false,
3762
4217
  global: true,
@@ -3770,7 +4225,7 @@ function createCli(argv) {
3770
4225
  type: "string",
3771
4226
  global: true,
3772
4227
  describe: "Override API base URL"
3773
- }).command(authCommand).command(chatCommand).command(messagesCommand).command(conversationsCommand).command(agentsCommand).command(keysCommand).command(secretsCommand).command(workspaceCommand).command(portalCommand).demandCommand(1, "Specify a command. Run --help for usage.").strict().wrap(null).version(version$1).alias("v", "version").alias("h", "help").help().fail((msg, err) => {
4228
+ }).command(authCommand).command(chatCommand).command(messagesCommand).command(conversationsCommand).command(agentsCommand).command(keysCommand).command(secretsCommand).command(workspaceCommand).command(portalCommand).command(sandboxCommand).demandCommand(1, "Specify a command. Run --help for usage.").strict().wrap(null).version(version$1).alias("v", "version").alias("h", "help").help().fail((msg, err) => {
3774
4229
  printError(err ? err instanceof Error ? err.message : String(err) : msg ?? "Unknown error");
3775
4230
  process.exit(1);
3776
4231
  });
@@ -3844,4 +4299,4 @@ function resolveArgv(args, tty = {
3844
4299
  createCli(resolveArgv(hideBin(process.argv))).parse();
3845
4300
 
3846
4301
  //#endregion
3847
- export { createRestClient as A, theme as C, themeVersion as D, themeModeFromColorFgBg as E, DEFAULT_APP_URL as F, getConfigPath as I, getSavedTheme as L, listWorkspaces as M, setActiveWorkspace as N, themesForMode as O, DEFAULT_API_URL as P, resolveWebUrl as R, noColorRequested as S, themeMode as T, isRecord as _, buildEnv as a, findTheme as b, resolveAgent as c, parseExternalOauthConnectParams as d, parseOauthConnectParams as f, errorMessage as g, parseConnectCard as h, mintPortalDeviceToken as i, getActiveWorkspaceId as j, HttpError as k, MASK_CHAR as l, resolveConnectUrl as m, findThisDevice as n, machineIdentity as o, reconcileMaskedInput as p, grantPortalAccess as r, portalWsUrl as s, fetchPortalDevices as t, cardActionErrorMessage as u, DEFAULT_THEME_ID as v, themeForMode as w, monoTheme as x, applyTheme as y, saveTheme as z };
4302
+ export { HttpError as A, saveTheme as B, noColorRequested as C, themeModeFromColorFgBg as D, themeMode as E, DEFAULT_API_URL as F, DEFAULT_APP_URL as I, getConfigPath as L, getActiveWorkspaceId as M, listWorkspaces as N, themeVersion as O, setActiveWorkspace as P, getSavedTheme as R, monoTheme as S, themeForMode as T, errorMessage as _, mintPortalDeviceToken as a, applyTheme as b, portalWsUrl as c, cardActionErrorMessage as d, parseExternalOauthConnectParams as f, parseConnectCard as g, resolveConnectUrl as h, grantPortalAccess as i, createRestClient as j, themesForMode as k, resolveAgent as l, reconcileMaskedInput as m, fetchPortalDevices as n, buildEnv as o, parseOauthConnectParams as p, findThisDevice as r, machineIdentity as s, SandboxStream as t, MASK_CHAR as u, isRecord as v, theme as w, findTheme as x, DEFAULT_THEME_ID as y, resolveWebUrl as z };
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { A as createRestClient, C as theme, D as themeVersion, E as themeModeFromColorFgBg, F as DEFAULT_APP_URL, I as getConfigPath, L as getSavedTheme, M as listWorkspaces, N as setActiveWorkspace, O as themesForMode, P as DEFAULT_API_URL, R as resolveWebUrl, S as noColorRequested, T as themeMode, _ as isRecord, b as findTheme, c as resolveAgent, d as parseExternalOauthConnectParams, f as parseOauthConnectParams, g as errorMessage, h as parseConnectCard, j as getActiveWorkspaceId, k as HttpError, l as MASK_CHAR, m as resolveConnectUrl, p as reconcileMaskedInput, u as cardActionErrorMessage, v as DEFAULT_THEME_ID, w as themeForMode, x as monoTheme, y as applyTheme, z as saveTheme } from "./bin.mjs";
3
- import { t as PortalClient } from "./client-_OL8-XGH.mjs";
2
+ import { A as HttpError, B as saveTheme, C as noColorRequested, D as themeModeFromColorFgBg, E as themeMode, F as DEFAULT_API_URL, I as DEFAULT_APP_URL, L as getConfigPath, M as getActiveWorkspaceId, N as listWorkspaces, O as themeVersion, P as setActiveWorkspace, R as getSavedTheme, S as monoTheme, T as themeForMode, _ as errorMessage, b as applyTheme, d as cardActionErrorMessage, f as parseExternalOauthConnectParams, g as parseConnectCard, h as resolveConnectUrl, j as createRestClient, k as themesForMode, l as resolveAgent, m as reconcileMaskedInput, p as parseOauthConnectParams, t as SandboxStream, u as MASK_CHAR, v as isRecord, w as theme, x as findTheme, y as DEFAULT_THEME_ID, z as resolveWebUrl } from "./bin.mjs";
3
+ import { t as PortalClient } from "./client-XFsd0Wy9.mjs";
4
+ import { n as runRawPtyPassthrough } from "./raw-pty-C1DXKms6.mjs";
5
+ import * as os$1 from "node:os";
6
+ import { homedir, platform, release, tmpdir } from "node:os";
4
7
  import path, { basename, extname, isAbsolute, join, win32 } from "node:path";
5
8
  import { z } from "zod";
6
9
  import open from "open";
7
10
  import { execFile, spawn } from "node:child_process";
8
11
  import { createHash } from "node:crypto";
9
12
  import { constants } from "node:fs";
10
- import * as os$1 from "node:os";
11
- import { homedir, platform, release, tmpdir } from "node:os";
12
13
  import { MarkdownRenderable, RenderableEvents, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks } from "@opentui/core";
13
14
  import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
14
15
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
15
16
  import { create } from "zustand";
16
- import { WebSocket } from "ws";
17
17
  import { createConnection } from "node:net";
18
18
  import { access, appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
19
19
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
@@ -5284,255 +5284,30 @@ function formatShellContext(command, output, exitCode) {
5284
5284
  ].join("\n");
5285
5285
  }
5286
5286
 
5287
- //#endregion
5288
- //#region ../sandbox-stream-protocol/src/index.ts
5289
- const SANDBOX_STREAM_PATH = "/api/v1/sandbox/stream";
5290
- const FRAME = {
5291
- DATA: 1,
5292
- EXIT: 2,
5293
- ERROR: 3,
5294
- INPUT: 16,
5295
- RESIZE: 17
5296
- };
5297
- const MAX_INPUT_BYTES = 1 * 1024 * 1024;
5298
- /** Query params for the upgrade URL, from a spec. Inverse of {@link parseStreamSpec}. */
5299
- function streamSpecToQuery(spec) {
5300
- if (spec.mode === "pty") return {
5301
- agentId: spec.agentId,
5302
- mode: "pty",
5303
- cols: String(spec.cols),
5304
- rows: String(spec.rows)
5305
- };
5306
- return {
5307
- agentId: spec.agentId,
5308
- mode: "exec",
5309
- command: spec.command
5310
- };
5311
- }
5312
- function withType(type, payload) {
5313
- const frame = new Uint8Array(1 + payload.length);
5314
- frame[0] = type;
5315
- frame.set(payload, 1);
5316
- return frame;
5317
- }
5318
- /** client → server: keystroke bytes for the pty stdin. */
5319
- function encodeInput(data) {
5320
- return withType(FRAME.INPUT, data);
5321
- }
5322
- /** client → server: the client terminal was resized. */
5323
- function encodeResize(cols, rows) {
5324
- const frame = new Uint8Array(5);
5325
- frame[0] = FRAME.RESIZE;
5326
- const view = new DataView(frame.buffer);
5327
- view.setUint16(1, cols & 65535);
5328
- view.setUint16(3, rows & 65535);
5329
- return frame;
5330
- }
5331
- const view = (frame) => new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
5332
- /**
5333
- * Decode a frame the server sent. Returns null for an empty, unknown, or
5334
- * truncated frame — a peer speaking a newer protocol must not crash us.
5335
- */
5336
- function decodeServerFrame(frame) {
5337
- const payload = frame.subarray(1);
5338
- switch (frame[0]) {
5339
- case FRAME.DATA: return {
5340
- type: "data",
5341
- payload
5342
- };
5343
- case FRAME.EXIT: return {
5344
- type: "exit",
5345
- code: payload.length >= 4 ? view(frame).getInt32(1) : 0
5346
- };
5347
- case FRAME.ERROR: return {
5348
- type: "error",
5349
- message: new TextDecoder().decode(payload)
5350
- };
5351
- default: return null;
5352
- }
5353
- }
5354
-
5355
- //#endregion
5356
- //#region src/chat/sandbox/client.ts
5357
- function wsBase(appUrl) {
5358
- const base = appUrl.replace(/\/+$/, "");
5359
- if (base.startsWith("https://")) return `wss://${base.slice(8)}`;
5360
- if (base.startsWith("http://")) return `ws://${base.slice(7)}`;
5361
- return `wss://${base}`;
5362
- }
5363
- /**
5364
- * A connected sandbox-stream session. Construct via `openSandboxStream`. Carries
5365
- * the write side (keystrokes / resize for pty mode) and teardown.
5366
- */
5367
- var SandboxStream = class SandboxStream {
5368
- ws;
5369
- closed = false;
5370
- constructor(ws, onEvent) {
5371
- this.ws = ws;
5372
- ws.on("message", (data, isBinary) => {
5373
- if (!isBinary) return;
5374
- const frame = decodeServerFrame(toBuffer(data));
5375
- if (!frame) return;
5376
- switch (frame.type) {
5377
- case "data":
5378
- onEvent({
5379
- type: "data",
5380
- bytes: new Uint8Array(frame.payload)
5381
- });
5382
- break;
5383
- case "exit":
5384
- onEvent({
5385
- type: "exit",
5386
- code: frame.code
5387
- });
5388
- break;
5389
- case "error":
5390
- onEvent({
5391
- type: "error",
5392
- message: frame.message
5393
- });
5394
- break;
5395
- }
5396
- });
5397
- ws.on("close", () => {
5398
- this.closed = true;
5399
- onEvent({ type: "close" });
5400
- });
5401
- ws.on("error", () => {});
5402
- }
5403
- /** Feed keystroke bytes to the pty stdin. */
5404
- sendInput(data) {
5405
- if (this.closed || this.ws.readyState !== WebSocket.OPEN) return;
5406
- this.ws.send(encodeInput(data));
5407
- }
5408
- /** Notify the pty of a terminal resize. */
5409
- resize(cols, rows) {
5410
- if (this.closed || this.ws.readyState !== WebSocket.OPEN) return;
5411
- this.ws.send(encodeResize(cols, rows));
5412
- }
5413
- close() {
5414
- this.closed = true;
5415
- this.ws.close();
5416
- }
5417
- /** Open a stream. `mode` is 'pty' (interactive) or 'exec' (one-shot). */
5418
- static open(opts) {
5419
- const spec = opts.mode === "pty" ? {
5420
- mode: "pty",
5421
- agentId: opts.agentId,
5422
- cols: opts.cols,
5423
- rows: opts.rows
5424
- } : {
5425
- mode: "exec",
5426
- agentId: opts.agentId,
5427
- command: opts.command
5428
- };
5429
- const url = new URL(`${wsBase(opts.appUrl)}${SANDBOX_STREAM_PATH}`);
5430
- for (const [key, value] of Object.entries(streamSpecToQuery(spec))) url.searchParams.set(key, value);
5431
- return new SandboxStream(new WebSocket(url.toString(), { headers: { authorization: `Bearer ${opts.sessionToken}` } }), opts.onEvent);
5432
- }
5433
- };
5434
- function toBuffer(data) {
5435
- if (Buffer.isBuffer(data)) return data;
5436
- if (Array.isArray(data)) return Buffer.concat(data);
5437
- return Buffer.from(data);
5438
- }
5439
-
5440
5287
  //#endregion
5441
5288
  //#region src/chat/sandbox/pty-session.ts
5442
5289
  /**
5443
5290
  * Runs a live interactive PTY into the agent's sandbox as a full-screen raw
5444
- * terminal, by SUSPENDING the opentui renderer for the duration and doing a
5445
- * direct byte passthrough:
5446
- *
5447
- * local stdin → INPUT frames sandbox pty
5448
- * sandbox pty → DATA frames → local stdout
5449
- *
5450
- * This is the correct shape for a real terminal: we don't reimplement a
5451
- * terminal emulator, we hand the actual TTY to the remote shell. On exit
5452
- * (the shell exits, the socket drops, or the user hits the detach key) we
5453
- * restore the terminal and resume the TUI.
5454
- *
5455
- * Detach key: Ctrl-] (0x1d), the classic telnet/ssh escape — leaves the shell
5456
- * running server-side is NOT a goal here; detaching closes the session.
5291
+ * terminal from inside the chat TUI, by SUSPENDING the opentui renderer for
5292
+ * the duration (restoring the normal screen buffer and releasing the terminal
5293
+ * and its input to us) and handing the TTY to the shared raw passthrough
5294
+ * (raw-pty.ts). On exit (the shell exits, the socket drops, or the user hits
5295
+ * Ctrl-]) the terminal is restored and the TUI resumes.
5457
5296
  */
5458
- const DETACH_BYTE = 29;
5459
5297
  async function runPtySession(opts) {
5460
5298
  const { renderer } = opts;
5461
- const stdin = renderer.stdin;
5462
- const stdout = process.stdout;
5463
- const size = () => ({
5464
- cols: stdout.columns ?? 80,
5465
- rows: stdout.rows ?? 24
5466
- });
5467
5299
  renderer.suspend();
5468
- return await new Promise((resolve) => {
5469
- let settled = false;
5470
- const initial = size();
5471
- const stream = SandboxStream.open({
5472
- mode: "pty",
5300
+ try {
5301
+ return await runRawPtyPassthrough({
5302
+ stdin: renderer.stdin,
5303
+ stdout: process.stdout,
5473
5304
  appUrl: opts.appUrl,
5474
5305
  sessionToken: opts.sessionToken,
5475
- agentId: opts.agentId,
5476
- cols: initial.cols,
5477
- rows: initial.rows,
5478
- onEvent: (e) => {
5479
- switch (e.type) {
5480
- case "data":
5481
- stdout.write(e.bytes);
5482
- break;
5483
- case "error":
5484
- stdout.write(`\r\n\x1b[31m${e.message}\x1b[0m\r\n`);
5485
- finish({
5486
- reason: "error",
5487
- code: 1
5488
- });
5489
- break;
5490
- case "exit":
5491
- finish({
5492
- reason: "exit",
5493
- code: e.code
5494
- });
5495
- break;
5496
- case "close":
5497
- finish({
5498
- reason: "exit",
5499
- code: 0
5500
- });
5501
- break;
5502
- }
5503
- }
5306
+ agentId: opts.agentId
5504
5307
  });
5505
- const onStdin = (chunk) => {
5506
- if (chunk.length === 1 && chunk[0] === DETACH_BYTE) {
5507
- finish({
5508
- reason: "detach",
5509
- code: 0
5510
- });
5511
- return;
5512
- }
5513
- stream.sendInput(new Uint8Array(chunk));
5514
- };
5515
- const onResize = () => {
5516
- const s = size();
5517
- stream.resize(s.cols, s.rows);
5518
- };
5519
- const wasRaw = stdin.isRaw ?? false;
5520
- stdin.setRawMode?.(true);
5521
- stdin.resume();
5522
- stdin.on("data", onStdin);
5523
- stdout.on("resize", onResize);
5524
- stream.resize(initial.cols, initial.rows);
5525
- function finish(result) {
5526
- if (settled) return;
5527
- settled = true;
5528
- stdin.off("data", onStdin);
5529
- stdout.off("resize", onResize);
5530
- stdin.setRawMode?.(wasRaw);
5531
- stream.close();
5532
- renderer.resume();
5533
- resolve(result);
5534
- }
5535
- });
5308
+ } finally {
5309
+ renderer.resume();
5310
+ }
5536
5311
  }
5537
5312
 
5538
5313
  //#endregion
@@ -6142,6 +5917,10 @@ function ChatScreen({ agent, conversation }) {
6142
5917
  append(`\n${e.message}`);
6143
5918
  stamp(1);
6144
5919
  } else if (e.type === "exit") stamp(e.code);
5920
+ else if (e.type === "close") {
5921
+ append(`\n${e.failure ? `connection failed: ${e.failure}` : "connection closed before the command finished"}`);
5922
+ stamp(1);
5923
+ }
6145
5924
  }
6146
5925
  });
6147
5926
  }, [
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as __exportAll } from "./rolldown-runtime-Cz4Tg37Z.mjs";
3
- import { a as buildEnv, g as errorMessage, i as mintPortalDeviceToken, n as findThisDevice, o as machineIdentity, r as grantPortalAccess, s as portalWsUrl, t as fetchPortalDevices } from "./bin.mjs";
3
+ import { _ as errorMessage, a as mintPortalDeviceToken, c as portalWsUrl, i as grantPortalAccess, n as fetchPortalDevices, o as buildEnv, r as findThisDevice, s as machineIdentity } from "./bin.mjs";
4
4
  import { z } from "zod";
5
5
  import { spawn } from "node:child_process";
6
6
  import { WebSocket } from "ws";
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { t as __exportAll } from "./rolldown-runtime-Cz4Tg37Z.mjs";
3
+ import { t as SandboxStream } from "./bin.mjs";
4
+
5
+ //#region src/chat/sandbox/raw-pty.ts
6
+ var raw_pty_exports = /* @__PURE__ */ __exportAll({ runRawPtyPassthrough: () => runRawPtyPassthrough });
7
+ /**
8
+ * The transport-and-TTY core of a live sandbox terminal: direct byte
9
+ * passthrough between a raw local TTY and the remote pty.
10
+ *
11
+ * local stdin → INPUT frames → sandbox pty
12
+ * sandbox pty → DATA frames → local stdout
13
+ *
14
+ * This is the correct shape for a real terminal: we don't reimplement a
15
+ * terminal emulator, we hand the actual TTY to the remote shell. Callers own
16
+ * the surrounding lifecycle — the chat TUI suspends/resumes its renderer
17
+ * around this (pty-session.ts), the standalone `skydive sandbox` command runs
18
+ * it bare.
19
+ *
20
+ * Detach key: Ctrl-] (0x1d), the classic telnet/ssh escape. Leaving the shell
21
+ * running server-side is NOT a goal here; detaching closes the session.
22
+ */
23
+ const DETACH_BYTE = 29;
24
+ async function runRawPtyPassthrough(opts) {
25
+ const { stdin, stdout } = opts;
26
+ const size = () => ({
27
+ cols: stdout.columns ?? 80,
28
+ rows: stdout.rows ?? 24
29
+ });
30
+ return await new Promise((resolve) => {
31
+ let settled = false;
32
+ const initial = size();
33
+ const stream = SandboxStream.open({
34
+ mode: "pty",
35
+ appUrl: opts.appUrl,
36
+ sessionToken: opts.sessionToken,
37
+ agentId: opts.agentId,
38
+ cols: initial.cols,
39
+ rows: initial.rows,
40
+ onEvent: (e) => {
41
+ switch (e.type) {
42
+ case "data":
43
+ stdout.write(e.bytes);
44
+ break;
45
+ case "error":
46
+ stdout.write(`\r\n\x1b[31m${e.message}\x1b[0m\r\n`);
47
+ finish({
48
+ reason: "error",
49
+ code: 1
50
+ });
51
+ break;
52
+ case "exit":
53
+ finish({
54
+ reason: "exit",
55
+ code: e.code
56
+ });
57
+ break;
58
+ case "close":
59
+ stdout.write(`\r\n\x1b[31m${e.failure ? `Could not open the sandbox terminal: ${e.failure}` : "The sandbox terminal connection closed unexpectedly."}\x1b[0m\r\n`);
60
+ finish({
61
+ reason: "error",
62
+ code: 1
63
+ });
64
+ break;
65
+ }
66
+ }
67
+ });
68
+ const onStdin = (chunk) => {
69
+ if (chunk.length === 1 && chunk[0] === DETACH_BYTE) {
70
+ finish({
71
+ reason: "detach",
72
+ code: 0
73
+ });
74
+ return;
75
+ }
76
+ stream.sendInput(new Uint8Array(chunk));
77
+ };
78
+ const onResize = () => {
79
+ const s = size();
80
+ stream.resize(s.cols, s.rows);
81
+ };
82
+ const wasRaw = stdin.isRaw ?? false;
83
+ stdin.setRawMode?.(true);
84
+ stdin.resume();
85
+ stdin.on("data", onStdin);
86
+ stdout.on("resize", onResize);
87
+ stream.resize(initial.cols, initial.rows);
88
+ function finish(result) {
89
+ if (settled) return;
90
+ settled = true;
91
+ stdin.off("data", onStdin);
92
+ stdout.off("resize", onResize);
93
+ stdin.setRawMode?.(wasRaw);
94
+ stream.close();
95
+ resolve(result);
96
+ }
97
+ });
98
+ }
99
+
100
+ //#endregion
101
+ export { runRawPtyPassthrough as n, raw_pty_exports as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skydive-cli",
3
- "version": "0.1.0-beta.353",
3
+ "version": "0.1.0-beta.378",
4
4
  "description": "Skydive CLI — manage AI agents from the command line",
5
5
  "homepage": "https://skydive.com",
6
6
  "license": "MIT",