skydive-cli 0.1.0-beta.363 → 0.1.0-beta.380

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,11 +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";
17
16
  import { WebSocket } from "ws";
18
17
 
19
18
  //#region package.json
20
- var version$1 = "0.1.0-beta.363";
19
+ var version$1 = "0.1.0-beta.380";
21
20
 
22
21
  //#endregion
23
22
  //#region src/types.ts
@@ -57,10 +56,18 @@ const DEFAULT_WEB_URL = "https://skydive.com";
57
56
  function resolveWebUrl(appUrl) {
58
57
  return appUrl === DEFAULT_APP_URL ? DEFAULT_WEB_URL : appUrl;
59
58
  }
60
- /** 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
61
60
  * `API_KEY_PREFIX` (`apps/anyone/api/src/lib/api-key.ts`); the CLI is a
62
61
  * standalone published package so it can't import the backend constant. */
63
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_";
64
71
  /** Where users mint and copy API keys. Shown in the login prompt. */
65
72
  const API_KEYS_URL = "skydive.com/settings/account";
66
73
  const store = new Conf({
@@ -79,9 +86,9 @@ function resolveConfig(opts) {
79
86
  }
80
87
  /**
81
88
  * Resolve the bearer credential for the management API (`agents` / `keys` /
82
- * `secrets`). The server's `/v1` gate accepts either an API key or the `--web`
83
- * session bearer, so both work — but only one of them tracks the active
84
- * 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.
85
92
  *
86
93
  * An API key is pinned server-side to the organization that minted it and
87
94
  * ignores the workspace header by design, so it can never follow `skydive
@@ -105,11 +112,17 @@ function resolveManagementAuth(opts) {
105
112
  apiUrl: process.env["SKYDIVE_API_URL"] ?? opts.apiUrl ?? store.get("apiUrl") ?? DEFAULT_API_URL,
106
113
  kind: "api-key"
107
114
  });
108
- 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`." });
109
116
  }
110
117
  function saveConfig(config) {
111
118
  store.set("apiKey", config.apiKey);
112
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;
113
126
  }
114
127
  function deleteConfig() {
115
128
  store.clear();
@@ -321,7 +334,7 @@ var SkydiveApiClient = class {
321
334
  if (body.error && typeof body.error === "string") message = body.error;
322
335
  else if (body.error?.message) message = body.error.message;
323
336
  } catch {}
324
- 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\`.`;
325
338
  return err({
326
339
  message,
327
340
  status: response.status
@@ -648,6 +661,94 @@ function sleep$1(ms) {
648
661
  return new Promise((resolve) => setTimeout(resolve, ms));
649
662
  }
650
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
+
651
752
  //#endregion
652
753
  //#region src/output.ts
653
754
  function output(argv, data) {
@@ -677,99 +778,160 @@ function printError(message) {
677
778
  //#region src/commands/auth.ts
678
779
  const loginCommand = {
679
780
  command: "login",
680
- describe: "Authenticate with a Skydive API key (or --web for chat)",
781
+ describe: "Sign in via the browser (use --api-key for CI / headless)",
681
782
  builder: (y) => y.option("api-key", {
682
783
  type: "string",
683
- describe: `API key (${API_KEY_PREFIX}...)`
784
+ describe: `Skip the browser and authenticate with an existing API key (${API_KEY_PREFIX}...)`
684
785
  }).option("web", {
685
786
  type: "boolean",
686
787
  default: false,
687
- 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"
688
790
  }),
689
791
  handler: async (argv) => {
690
- if (argv.web) {
691
- await runWebLogin(argv);
792
+ if (argv["api-key"]) {
793
+ await runApiKeyLogin(argv, argv["api-key"]);
692
794
  return;
693
795
  }
694
- let apiKey = argv["api-key"] ?? process.env["SKYDIVE_API_KEY"];
695
- if (!apiKey) {
696
- const rl = createInterface({
697
- input: process.stdin,
698
- output: process.stderr
699
- });
700
- apiKey = await new Promise((resolve) => {
701
- rl.question(`Enter your API key (from ${API_KEYS_URL}): `, (answer) => {
702
- rl.close();
703
- resolve(answer.trim());
704
- });
705
- });
706
- }
707
- if (!apiKey) {
708
- printError("No API key provided.");
709
- process.exit(1);
710
- }
711
- if (!apiKey.startsWith(API_KEY_PREFIX)) {
712
- printError(`API key must start with ${API_KEY_PREFIX}`);
713
- process.exit(1);
714
- }
715
- const apiUrl = argv["api-url"] ?? DEFAULT_API_URL;
716
- const result = await new SkydiveApiClient({
717
- token: apiKey,
718
- apiUrl,
719
- kind: "api-key"
720
- }).listAgents({
721
- limit: 1,
722
- scope: null
723
- });
724
- if (result.isErr()) {
725
- printError(`Invalid API key or unreachable server: ${result.error.message}`);
726
- 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
+ }
727
802
  }
728
- saveConfig({
729
- apiKey,
730
- apiUrl
731
- });
732
- 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, {
733
852
  authenticated: true,
734
- 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,
735
858
  configPath: getConfigPath()
736
859
  });
737
- else if (!argv.quiet) {
738
- console.log(`Authenticated successfully.`);
739
- console.log(` Key: ${apiKey.slice(0, 12)}...`);
740
- console.log(` Config: ${getConfigPath()}`);
741
- }
860
+ return;
742
861
  }
743
- };
744
- async function runWebLogin(argv) {
745
- if (isNonInteractive()) {
746
- 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}`);
747
873
  process.exit(1);
748
874
  }
749
- 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
+ });
750
884
  if (result.isErr()) {
751
- printError(result.error.message);
885
+ printError(`Invalid API key or unreachable server: ${result.error.message}`);
752
886
  process.exit(1);
753
887
  }
888
+ saveConfig({
889
+ apiKey,
890
+ apiUrl,
891
+ apiKeyId: null
892
+ });
754
893
  if (argv.json) output(argv, {
755
894
  authenticated: true,
756
- mode: "session",
757
- appUrl: result.value.appUrl,
895
+ prefix: apiKey.slice(0, 12),
758
896
  configPath: getConfigPath()
759
897
  });
760
898
  else if (!argv.quiet) {
761
- console.log("Signed in for chat.");
762
- console.log(` App: ${result.value.appUrl}`);
899
+ console.log(`Authenticated successfully.`);
900
+ console.log(` Key: ${apiKey.slice(0, 12)}...`);
763
901
  console.log(` Config: ${getConfigPath()}`);
764
902
  }
765
903
  }
766
904
  const logoutCommand = {
767
905
  command: "logout",
768
- describe: "Clear stored credentials (API key and chat session)",
906
+ describe: "Revoke the auto-minted API key and clear stored credentials",
769
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
+ }
770
929
  deleteConfig();
771
- if (argv.json) output(argv, { authenticated: false });
772
- 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.");
773
935
  }
774
936
  };
775
937
  const statusCommand$1 = {
@@ -802,7 +964,7 @@ const statusCommand$1 = {
802
964
  return;
803
965
  }
804
966
  if (apiKey.isErr() && session.isErr()) {
805
- 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`.");
806
968
  return;
807
969
  }
808
970
  if (apiKey.isOk()) {
@@ -841,7 +1003,8 @@ const authCommand = {
841
1003
  //#region src/chat/api/rest.ts
842
1004
  var rest_exports = /* @__PURE__ */ __exportAll({
843
1005
  HttpError: () => HttpError,
844
- createRestClient: () => createRestClient
1006
+ createRestClient: () => createRestClient,
1007
+ errorDetail: () => errorDetail
845
1008
  });
846
1009
  var HttpError = class extends Error {
847
1010
  constructor(status, body) {
@@ -851,6 +1014,20 @@ var HttpError = class extends Error {
851
1014
  this.name = "HttpError";
852
1015
  }
853
1016
  };
1017
+ const ERROR_DETAIL_MAX_BODY = 2e3;
1018
+ /**
1019
+ * Fullest renderable text for a thrown value. `HttpError.message` clips the
1020
+ * response body to 200 chars (it flows into logs and one-line UIs); the
1021
+ * transcript renders errors collapsed to a single line, so it can afford the
1022
+ * whole body — capped with an explicit marker, never cut silently.
1023
+ */
1024
+ function errorDetail(err) {
1025
+ if (err instanceof HttpError) {
1026
+ const body = err.body.length > ERROR_DETAIL_MAX_BODY ? `${err.body.slice(0, ERROR_DETAIL_MAX_BODY)}… (+${err.body.length - ERROR_DETAIL_MAX_BODY} chars)` : err.body;
1027
+ return body ? `HTTP ${err.status}: ${body}` : `HTTP ${err.status}`;
1028
+ }
1029
+ return err instanceof Error ? err.message : String(err);
1030
+ }
854
1031
  const MAX_STREAM_RECONNECTS = 5;
855
1032
  function createRestClient({ appUrl, sessionToken }) {
856
1033
  const baseHeaders = {
@@ -1262,7 +1439,7 @@ const conversationStreamEventSchema = z.discriminatedUnion("kind", [z.object({
1262
1439
  function requireSession(argv) {
1263
1440
  const session = resolveSession({ appUrl: resolveAppUrl({ appUrl: argv["api-url"] }) });
1264
1441
  if (session.isErr()) {
1265
- printError(`${session.error.message} Run \`skydive auth login --web\` first, or set SKYDIVE_SESSION_TOKEN.`);
1442
+ printError(`${session.error.message} Run \`skydive auth login\` first, or set SKYDIVE_SESSION_TOKEN.`);
1266
1443
  process.exit(1);
1267
1444
  }
1268
1445
  return session.value;
@@ -2597,7 +2774,7 @@ const chatCommand = {
2597
2774
  let session = resolveSession({ appUrl });
2598
2775
  if (session.isErr()) {
2599
2776
  if (isNonInteractive()) {
2600
- printError("Not signed in for chat and no interactive terminal. Run `skydive auth login --web`, or set SKYDIVE_SESSION_TOKEN.");
2777
+ printError("Not signed in for chat and no interactive terminal. Run `skydive auth login`, or set SKYDIVE_SESSION_TOKEN.");
2601
2778
  process.exit(1);
2602
2779
  }
2603
2780
  const login = await loginWithDevice({ appUrl });
@@ -2616,7 +2793,7 @@ const chatCommand = {
2616
2793
  printError(`Unknown theme "${themeId}". Valid themes: ${themes.map((t) => t.id).join(", ")}`);
2617
2794
  process.exit(1);
2618
2795
  }
2619
- const { runChat } = await import("./boot-C1WUzzUo.mjs");
2796
+ const { runChat } = await import("./boot-DML2xNb6.mjs");
2620
2797
  await runChat({
2621
2798
  appUrl,
2622
2799
  sessionToken: session.value.sessionToken,
@@ -2640,7 +2817,7 @@ function resolveShareMachine(argv) {
2640
2817
  async function runPrintMode({ argv, appUrl }) {
2641
2818
  const session = resolveSession({ appUrl });
2642
2819
  if (session.isErr()) {
2643
- printError(`${session.error.message} For non-interactive use, run \`skydive auth login --web\` first, or set SKYDIVE_SESSION_TOKEN.`);
2820
+ printError(`${session.error.message} For non-interactive use, run \`skydive auth login\` first, or set SKYDIVE_SESSION_TOKEN.`);
2644
2821
  process.exit(1);
2645
2822
  }
2646
2823
  const { runPrint, readStdin } = await Promise.resolve().then(() => print_exports);
@@ -2728,7 +2905,7 @@ const getCommand = {
2728
2905
  const appUrl = resolveAppUrl({ appUrl: argv["api-url"] });
2729
2906
  const session = resolveSession({ appUrl });
2730
2907
  if (session.isErr()) {
2731
- printError(`${session.error.message} Run \`skydive auth login --web\` first, or set SKYDIVE_SESSION_TOKEN.`);
2908
+ printError(`${session.error.message} Run \`skydive auth login\` first, or set SKYDIVE_SESSION_TOKEN.`);
2732
2909
  process.exit(1);
2733
2910
  }
2734
2911
  const { messageGet } = await Promise.resolve().then(() => print_exports);
@@ -3410,7 +3587,7 @@ const switchCommand = {
3410
3587
  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.");
3411
3588
  process.exit(1);
3412
3589
  }
3413
- const { runWorkspacePicker } = await import("./boot-C1WUzzUo.mjs");
3590
+ const { runWorkspacePicker } = await import("./boot-DML2xNb6.mjs");
3414
3591
  await runWorkspacePicker(session);
3415
3592
  return;
3416
3593
  }
@@ -3538,8 +3715,8 @@ async function portalFetch(auth, path, init) {
3538
3715
  ...init.body ? { body: init.body } : {}
3539
3716
  });
3540
3717
  if (!res.ok) {
3541
- const body = await res.text().then((text) => text.slice(0, 120)).catch(() => "");
3542
- throw new Error(`${init.method} ${path} failed (${res.status}): ${body}`);
3718
+ const body = await res.text().catch(() => "");
3719
+ throw new HttpError(res.status, body);
3543
3720
  }
3544
3721
  return res.json();
3545
3722
  }
@@ -4137,4 +4314,4 @@ function resolveArgv(args, tty = {
4137
4314
  createCli(resolveArgv(hideBin(process.argv))).parse();
4138
4315
 
4139
4316
  //#endregion
4140
- 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 };
4317
+ export { HttpError as A, resolveWebUrl as B, noColorRequested as C, themeModeFromColorFgBg as D, themeMode as E, setActiveWorkspace as F, DEFAULT_API_URL as I, DEFAULT_APP_URL as L, errorDetail as M, getActiveWorkspaceId as N, themeVersion as O, listWorkspaces as P, getConfigPath as R, monoTheme as S, themeForMode as T, saveTheme as V, 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, getSavedTheme as z };
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
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";
2
+ import { A as HttpError, B as resolveWebUrl, C as noColorRequested, D as themeModeFromColorFgBg, E as themeMode, F as setActiveWorkspace, I as DEFAULT_API_URL, L as DEFAULT_APP_URL, M as errorDetail, N as getActiveWorkspaceId, O as themeVersion, P as listWorkspaces, R as getConfigPath, S as monoTheme, T as themeForMode, V as saveTheme, _ 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 getSavedTheme } from "./bin.mjs";
3
3
  import { t as PortalClient } from "./client-XFsd0Wy9.mjs";
4
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";
5
7
  import path, { basename, extname, isAbsolute, join, win32 } from "node:path";
6
8
  import { z } from "zod";
7
9
  import open from "open";
8
10
  import { execFile, spawn } from "node:child_process";
9
11
  import { createHash } from "node:crypto";
10
12
  import { constants } from "node:fs";
11
- import * as os$1 from "node:os";
12
- import { homedir, platform, release, tmpdir } from "node:os";
13
13
  import { MarkdownRenderable, RenderableEvents, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks } from "@opentui/core";
14
14
  import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
15
15
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
@@ -2106,6 +2106,51 @@ function StatusLine({ item }) {
2106
2106
  }
2107
2107
  }
2108
2108
 
2109
+ //#endregion
2110
+ //#region src/chat/tui/chunks/error-item.tsx
2111
+ /**
2112
+ * Transcript error block. Anything longer than one line collapses to its
2113
+ * first line — an HTTP error body or a stack must not bury the conversation —
2114
+ * and clicking toggles the full text, matching the reasoning/tool blocks.
2115
+ */
2116
+ function ErrorItem({ item }) {
2117
+ const [expanded, setExpanded] = useState(false);
2118
+ const text = item.text.trim();
2119
+ const firstLine = text.split("\n").find((line) => line.trim()) ?? text;
2120
+ if (!(text.includes("\n") || firstLine.length > 100)) return /* @__PURE__ */ jsxs("text", {
2121
+ fg: theme.error,
2122
+ children: ["✗ ", text]
2123
+ });
2124
+ if (expanded) return /* @__PURE__ */ jsxs("box", {
2125
+ style: {
2126
+ flexDirection: "column",
2127
+ backgroundColor: theme.surface
2128
+ },
2129
+ onMouseDown: () => setExpanded(false),
2130
+ children: [/* @__PURE__ */ jsx("text", {
2131
+ fg: theme.error,
2132
+ children: "✗ error"
2133
+ }), /* @__PURE__ */ jsxs(Indented, { children: [/* @__PURE__ */ jsx(ExpandedLines, {
2134
+ text,
2135
+ fg: theme.error
2136
+ }), /* @__PURE__ */ jsx(CollapseHint, {})] })]
2137
+ });
2138
+ return /* @__PURE__ */ jsx("box", {
2139
+ onMouseDown: () => setExpanded(true),
2140
+ children: /* @__PURE__ */ jsxs("text", {
2141
+ fg: theme.error,
2142
+ children: [
2143
+ "✗ ",
2144
+ truncate(firstLine, 100),
2145
+ /* @__PURE__ */ jsx("span", {
2146
+ fg: theme.dim,
2147
+ children: " · click to expand"
2148
+ })
2149
+ ]
2150
+ })
2151
+ });
2152
+ }
2153
+
2109
2154
  //#endregion
2110
2155
  //#region src/chat/tui/chunks/tool-call-summary.ts
2111
2156
  const TOOL_CALL_SUMMARY_KEY = "__skydive_summary__";
@@ -3018,10 +3063,7 @@ function RenderItem({ item }) {
3018
3063
  });
3019
3064
  case "error": return /* @__PURE__ */ jsx(Row, {
3020
3065
  barColor: null,
3021
- children: /* @__PURE__ */ jsx("text", {
3022
- fg: theme.error,
3023
- children: item.text
3024
- })
3066
+ children: /* @__PURE__ */ jsx(ErrorItem, { item })
3025
3067
  });
3026
3068
  case "card": return /* @__PURE__ */ jsx(Row, {
3027
3069
  barColor: null,
@@ -5485,7 +5527,7 @@ function ChatScreen({ agent, conversation }) {
5485
5527
  setItems((prev) => [...prev, {
5486
5528
  kind: "error",
5487
5529
  id: crypto.randomUUID(),
5488
- text: `failed to load history: ${errorMessage(err)}`
5530
+ text: `failed to load history: ${errorDetail(err)}`
5489
5531
  }]);
5490
5532
  } finally {
5491
5533
  if (!cancelled) setHistoryLoaded(true);
@@ -5541,7 +5583,7 @@ function ChatScreen({ agent, conversation }) {
5541
5583
  setItems((prev) => [...prev, {
5542
5584
  kind: "error",
5543
5585
  id: crypto.randomUUID(),
5544
- text: errorMessage(err)
5586
+ text: errorDetail(err)
5545
5587
  }]);
5546
5588
  setRun({ kind: "idle" });
5547
5589
  });
@@ -5608,7 +5650,7 @@ function ChatScreen({ agent, conversation }) {
5608
5650
  setItems((prev) => [...prev, {
5609
5651
  kind: "error",
5610
5652
  id: crypto.randomUUID(),
5611
- text: `attachment failed: ${row.fileName}${result.status === "rejected" ? ` (${errorMessage(result.reason)})` : ""}`
5653
+ text: `attachment failed: ${row.fileName}${result.status === "rejected" ? ` (${errorDetail(result.reason)})` : ""}`
5612
5654
  }]);
5613
5655
  }
5614
5656
  }
@@ -5642,7 +5684,7 @@ function ChatScreen({ agent, conversation }) {
5642
5684
  setItems((prev) => [...prev.filter((m) => m.id !== optimisticId), {
5643
5685
  kind: "error",
5644
5686
  id: crypto.randomUUID(),
5645
- text: errorMessage(err)
5687
+ text: errorDetail(err)
5646
5688
  }]);
5647
5689
  if (echo) setInput((existing) => existing ? existing : trimmed);
5648
5690
  const restore = staged.filter((a) => uploadsRef.current.has(a.tempId));
@@ -5670,7 +5712,7 @@ function ChatScreen({ agent, conversation }) {
5670
5712
  setItems((prev) => [...prev, {
5671
5713
  kind: "error",
5672
5714
  id: crypto.randomUUID(),
5673
- text: `steer cancel failed: ${errorMessage(err)}`
5715
+ text: `steer cancel failed: ${errorDetail(err)}`
5674
5716
  }]);
5675
5717
  });
5676
5718
  return true;
@@ -6131,7 +6173,7 @@ function ChatScreen({ agent, conversation }) {
6131
6173
  })).catch((err) => setItems((prev) => [...prev, {
6132
6174
  kind: "error",
6133
6175
  id: crypto.randomUUID(),
6134
- text: `couldn't share machine: ${errorMessage(err)}`
6176
+ text: `couldn't share machine: ${errorDetail(err)}`
6135
6177
  }]));
6136
6178
  }, [
6137
6179
  portalClient,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skydive-cli",
3
- "version": "0.1.0-beta.363",
3
+ "version": "0.1.0-beta.380",
4
4
  "description": "Skydive CLI — manage AI agents from the command line",
5
5
  "homepage": "https://skydive.com",
6
6
  "license": "MIT",