skydive-cli 0.2.0 → 0.3.0
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/CHANGELOG.md +42 -0
- package/README.md +114 -22
- package/dist/js/bin.mjs +1261 -128
- package/dist/js/{boot-CGaXUEer.mjs → boot-HAgDg_b4.mjs} +2507 -194
- package/dist/js/{client-B3ZhhF7e.mjs → client-Dc7GZ3PG.mjs} +148 -8
- package/dist/js/client-DuwxEDG4.mjs +5 -0
- package/dist/js/daemon-Ch_MSDmQ.mjs +6 -0
- package/dist/js/daemon-Co4CtpXZ.mjs +607 -0
- package/dist/js/{theme-CuQhvqzN.mjs → install-CMYBFvR2.mjs} +256 -186
- package/dist/js/{print-BZH0b3KB.mjs → print-CPCFci60.mjs} +2 -2
- package/dist/js/{print-Bin4u16d.mjs → print-Cd-bVSEJ.mjs} +224 -10
- package/dist/js/{print-share-D7OSxvE2.mjs → print-share-7qfpQtWK.mjs} +3 -3
- package/dist/js/{raw-pty-GAvxm2ol.mjs → raw-pty-BcjbTjHJ.mjs} +1 -1
- package/dist/js/raw-pty-ChUHav4d.mjs +5 -0
- package/dist/js/{rest-CamHVOce.mjs → rest-BlN_uWmL.mjs} +150 -19
- package/dist/js/rest-CDTXCmUb.mjs +4 -0
- package/package.json +3 -1
- package/dist/js/api-CDTKq_5Q.mjs +0 -145
- package/dist/js/client-D6NAkL9e.mjs +0 -6
- package/dist/js/raw-pty-2_VA1kw_.mjs +0 -5
- package/dist/js/rest-COkLEZOB.mjs +0 -4
- package/dist/js/util-CeisaZVY.mjs +0 -15
- /package/dist/js/{client-CpEvH2Pq.mjs → client-DfcJFEbh.mjs} +0 -0
- /package/dist/js/{output-B4cW10Ph.mjs → output-DYzzdXYV.mjs} +0 -0
|
@@ -1,7 +1,191 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as createRestClient, t as HttpError } from "./rest-
|
|
3
|
-
import
|
|
2
|
+
import { a as errorMessage, i as sendErrorMessage, n as createRestClient, o as isRecord, t as HttpError } from "./rest-BlN_uWmL.mjs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import Conf from "conf";
|
|
5
|
+
import { err, ok } from "neverthrow";
|
|
6
|
+
import stableStringify from "safe-stable-stringify";
|
|
4
7
|
|
|
8
|
+
//#region src/config.ts
|
|
9
|
+
/** Default host for the public management API (`/v1`, API-key auth). */
|
|
10
|
+
const DEFAULT_API_URL = "https://api.skydive.com";
|
|
11
|
+
/**
|
|
12
|
+
* Default origin for the interactive chat client (`skydive chat`).
|
|
13
|
+
*
|
|
14
|
+
* The API host that serves better-auth (`/api/auth/*`) and the internal tRPC
|
|
15
|
+
* API (`/api/v1/trpc`) that chat streams over. We target the API host
|
|
16
|
+
* directly (not the web front door) because chat opens a WebSocket and
|
|
17
|
+
* authenticates with a bearer token on the upgrade request. Same host as
|
|
18
|
+
* `DEFAULT_API_URL`; override with `--api-url` / `SKYDIVE_APP_URL` for local
|
|
19
|
+
* dev or while the DNS record is still being provisioned.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_APP_URL = "https://api.skydive.com";
|
|
22
|
+
/** Web front door, for pages opened in the user's browser. */
|
|
23
|
+
const DEFAULT_WEB_URL = "https://skydive.com";
|
|
24
|
+
function resolveWebUrl(appUrl) {
|
|
25
|
+
if (appUrl == null) return appUrl;
|
|
26
|
+
return appUrl === DEFAULT_APP_URL ? DEFAULT_WEB_URL : appUrl;
|
|
27
|
+
}
|
|
28
|
+
/** Prefix on workspace-scoped Skydive API keys. Kept in sync with the API's
|
|
29
|
+
* `API_KEY_PREFIX` (`apps/anyone/api/src/lib/api-key.ts`); the CLI is a
|
|
30
|
+
* standalone published package so it can't import the backend constant. */
|
|
31
|
+
const API_KEY_PREFIX = "sky_live_";
|
|
32
|
+
/**
|
|
33
|
+
* Common prefix across all Skydive API key kinds — `sky_live_…` workspace
|
|
34
|
+
* keys today, `sky_user_…` account keys when ANY-5105 lands. The CLI only
|
|
35
|
+
* sanity-checks the family on `--api-key`; the server authoritatively rejects
|
|
36
|
+
* a kind that can't drive a given route, with a clearer message than the
|
|
37
|
+
* client could produce.
|
|
38
|
+
*/
|
|
39
|
+
const API_KEY_FAMILY_PREFIX = "sky_";
|
|
40
|
+
/** Where users mint and copy API keys. Shown in the login prompt. */
|
|
41
|
+
const API_KEYS_URL = "skydive.com/settings/account";
|
|
42
|
+
const store = new Conf({
|
|
43
|
+
projectName: process.env["SKYDIVE_CONFIG_NAME"] ?? "skydive",
|
|
44
|
+
projectSuffix: "",
|
|
45
|
+
configFileMode: 384
|
|
46
|
+
});
|
|
47
|
+
function resolveConfig(opts) {
|
|
48
|
+
const apiKey = process.env["SKYDIVE_API_KEY"] ?? store.get("apiKey");
|
|
49
|
+
const apiUrl = process.env["SKYDIVE_API_URL"] ?? opts.apiUrl ?? store.get("apiUrl") ?? DEFAULT_API_URL;
|
|
50
|
+
if (!apiKey) return err({ message: "Not authenticated. Run `skydive auth login` first." });
|
|
51
|
+
return ok({
|
|
52
|
+
apiKey,
|
|
53
|
+
apiUrl
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the bearer credential for the management API (`agents` / `keys` /
|
|
58
|
+
* `secrets`). The server's `/v1` gate accepts either an API key or the
|
|
59
|
+
* device-flow session bearer, so both work — but only one of them tracks the
|
|
60
|
+
* active workspace.
|
|
61
|
+
*
|
|
62
|
+
* An API key is pinned server-side to the organization that minted it and
|
|
63
|
+
* ignores the workspace header by design, so it can never follow `skydive
|
|
64
|
+
* workspace switch`. A key is also a strictly narrower credential than its
|
|
65
|
+
* owner's session. So the session wins whenever there is one, and a key is
|
|
66
|
+
* what's left for machines that never ran an interactive login.
|
|
67
|
+
*
|
|
68
|
+
* `SKYDIVE_SESSION_TOKEN=` (empty) suppresses session auth for one
|
|
69
|
+
* invocation, to drive a specific organization's key while signed in.
|
|
70
|
+
*/
|
|
71
|
+
function resolveManagementAuth(opts) {
|
|
72
|
+
const session = resolveSession({ appUrl: opts.apiUrl });
|
|
73
|
+
if (session.isOk()) return ok({
|
|
74
|
+
token: session.value.sessionToken,
|
|
75
|
+
apiUrl: session.value.appUrl,
|
|
76
|
+
kind: "session",
|
|
77
|
+
pinnedWorkspaceName: null
|
|
78
|
+
});
|
|
79
|
+
const envKey = process.env["SKYDIVE_API_KEY"];
|
|
80
|
+
const storedKey = store.get("apiKey");
|
|
81
|
+
const apiKey = envKey ?? storedKey;
|
|
82
|
+
if (apiKey) return ok({
|
|
83
|
+
token: apiKey,
|
|
84
|
+
apiUrl: process.env["SKYDIVE_API_URL"] ?? opts.apiUrl ?? store.get("apiUrl") ?? DEFAULT_API_URL,
|
|
85
|
+
kind: "api-key",
|
|
86
|
+
pinnedWorkspaceName: !envKey && storedKey ? store.get("apiKeyWorkspaceName") ?? null : null
|
|
87
|
+
});
|
|
88
|
+
return err({ message: "Not authenticated. Run `skydive auth login`." });
|
|
89
|
+
}
|
|
90
|
+
function saveConfig(config) {
|
|
91
|
+
store.set("apiKey", config.apiKey);
|
|
92
|
+
store.set("apiUrl", config.apiUrl);
|
|
93
|
+
if (config.apiKeyId) store.set("apiKeyId", config.apiKeyId);
|
|
94
|
+
else store.delete("apiKeyId");
|
|
95
|
+
if (config.workspaceName) store.set("apiKeyWorkspaceName", config.workspaceName);
|
|
96
|
+
else store.delete("apiKeyWorkspaceName");
|
|
97
|
+
}
|
|
98
|
+
/** Server-side id of the auto-minted key, if login minted one. */
|
|
99
|
+
function getStoredApiKeyId() {
|
|
100
|
+
return store.get("apiKeyId") ?? null;
|
|
101
|
+
}
|
|
102
|
+
/** Workspace the auto-minted key is pinned to, if login recorded one. */
|
|
103
|
+
function getStoredApiKeyWorkspaceName() {
|
|
104
|
+
return store.get("apiKeyWorkspaceName") ?? null;
|
|
105
|
+
}
|
|
106
|
+
function deleteConfig() {
|
|
107
|
+
store.clear();
|
|
108
|
+
}
|
|
109
|
+
function getConfigPath() {
|
|
110
|
+
return store.path;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Where the chat TUI persists its prompt history (up-arrow recall). Kept
|
|
114
|
+
* beside the config file so all CLI state lives in one directory.
|
|
115
|
+
*/
|
|
116
|
+
function getLastSeenVersion() {
|
|
117
|
+
return store.get("lastSeenVersion");
|
|
118
|
+
}
|
|
119
|
+
function setLastSeenVersion(version) {
|
|
120
|
+
store.set("lastSeenVersion", version);
|
|
121
|
+
}
|
|
122
|
+
function getPromptHistoryPath() {
|
|
123
|
+
return path.join(path.dirname(store.path), "prompt-history.jsonl");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Where the chat TUI persists pending review comments — one JSON file per
|
|
127
|
+
* conversation, so a pending comment survives conversation switches and
|
|
128
|
+
* process death. Kept beside the config file like prompt history.
|
|
129
|
+
*/
|
|
130
|
+
function getReviewStateDir() {
|
|
131
|
+
return path.join(path.dirname(store.path), "review");
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the chat/auth origin. Precedence: `SKYDIVE_APP_URL` env > explicit
|
|
135
|
+
* `--api-url` style override > stored value > `SKYDIVE_API_URL` env >
|
|
136
|
+
* `DEFAULT_APP_URL`.
|
|
137
|
+
*
|
|
138
|
+
* The `SKYDIVE_API_URL` fallback matters for previews: the device/`--web`
|
|
139
|
+
* flow and chat hit the same api service as the management API, so pointing
|
|
140
|
+
* `SKYDIVE_API_URL` at a preview stack is enough — you don't also have to set
|
|
141
|
+
* `SKYDIVE_APP_URL`. Otherwise auth would silently fall through to prod
|
|
142
|
+
* (`DEFAULT_APP_URL`) and hand back a prod verification URL.
|
|
143
|
+
*/
|
|
144
|
+
function resolveAppUrl(opts) {
|
|
145
|
+
return process.env["SKYDIVE_APP_URL"] ?? opts.appUrl ?? store.get("appUrl") ?? process.env["SKYDIVE_API_URL"] ?? DEFAULT_APP_URL;
|
|
146
|
+
}
|
|
147
|
+
function resolveSession(opts) {
|
|
148
|
+
const sessionToken = process.env["SKYDIVE_SESSION_TOKEN"] ?? store.get("sessionToken");
|
|
149
|
+
const appUrl = resolveAppUrl(opts);
|
|
150
|
+
if (!sessionToken) return err({ message: "Not signed in for chat. Run `skydive chat` to sign in." });
|
|
151
|
+
return ok({
|
|
152
|
+
sessionToken,
|
|
153
|
+
appUrl
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function saveSession(session) {
|
|
157
|
+
store.set("sessionToken", session.sessionToken);
|
|
158
|
+
store.set("sessionObtainedAt", (/* @__PURE__ */ new Date()).toISOString());
|
|
159
|
+
store.set("appUrl", session.appUrl);
|
|
160
|
+
}
|
|
161
|
+
function getSavedTheme(mode) {
|
|
162
|
+
return store.get(mode === "dark" ? "themeDark" : "themeLight");
|
|
163
|
+
}
|
|
164
|
+
function saveTheme(mode, themeId) {
|
|
165
|
+
store.set(mode === "dark" ? "themeDark" : "themeLight", themeId);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether `skydive chat` should enable portal machine sharing on launch
|
|
169
|
+
* without `--share-machine`. Set by hand-editing `shareMachineDefault` in
|
|
170
|
+
* config.json — deliberately no CLI command (kept off the API surface).
|
|
171
|
+
* Sharing only makes the machine reachable — agents still need a
|
|
172
|
+
* (persistent) grant to run anything, so this default skips the per-session
|
|
173
|
+
* enable step, not the consent step.
|
|
174
|
+
*/
|
|
175
|
+
function getShareMachineDefault() {
|
|
176
|
+
return store.get("shareMachineDefault") ?? false;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Whether the update check is disabled via config.json (`"updateCheck":
|
|
180
|
+
* false`). The persistent counterpart to the SKYDIVE_NO_UPDATE_CHECK /
|
|
181
|
+
* NO_UPDATE_NOTIFIER env opt-outs; like shareMachineDefault, set by editing
|
|
182
|
+
* config.json — deliberately no CLI command.
|
|
183
|
+
*/
|
|
184
|
+
function getUpdateCheckDisabled() {
|
|
185
|
+
return store.get("updateCheck") === false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
//#endregion
|
|
5
189
|
//#region src/chat/tui/chat/card.ts
|
|
6
190
|
const urlActionKinds = [
|
|
7
191
|
"open_oauth",
|
|
@@ -55,6 +239,23 @@ function parseButton(element) {
|
|
|
55
239
|
primary
|
|
56
240
|
};
|
|
57
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Content identity for a card's spec, which is what separates a re-delivery of
|
|
244
|
+
* one card from two distinct cards: the same card reaches us twice (live on the
|
|
245
|
+
* run stream and again as the persisted message part when history is loaded for
|
|
246
|
+
* an in-flight run), while a single tool call can legitimately post SEVERAL
|
|
247
|
+
* different cards — `platform` subprocesses chained in one bash command all
|
|
248
|
+
* read the same TOOL_CALL_ID, so `toolCallId` alone identifies neither. Applies
|
|
249
|
+
* the same identity rule as the web client (web/src/features/chat-v2/turn-segments.ts).
|
|
250
|
+
*
|
|
251
|
+
* The serialization has to be key-order-independent, because the persisted copy
|
|
252
|
+
* of a spec comes back through a jsonb round-trip that can reorder keys and
|
|
253
|
+
* order-sensitive `JSON.stringify` would read that as a different card.
|
|
254
|
+
* `safe-stable-stringify` sorts keys and tolerates cycles.
|
|
255
|
+
*/
|
|
256
|
+
function specKeyFor(spec) {
|
|
257
|
+
return stableStringify(spec) ?? crypto.randomUUID();
|
|
258
|
+
}
|
|
58
259
|
function parseConnectCard(spec) {
|
|
59
260
|
if (!isRecord(spec)) return null;
|
|
60
261
|
const { root, elements } = spec;
|
|
@@ -210,14 +411,20 @@ function summarizeConnectCard(card, appUrl) {
|
|
|
210
411
|
const act = button.action;
|
|
211
412
|
switch (act.kind) {
|
|
212
413
|
case "open_oauth":
|
|
213
|
-
case "open_external_oauth":
|
|
214
|
-
case "open_github_app": return {
|
|
414
|
+
case "open_external_oauth": return {
|
|
215
415
|
...base,
|
|
216
416
|
action: {
|
|
217
417
|
kind: "open_url",
|
|
218
418
|
url: resolveConnectUrl(act.url, appUrl)
|
|
219
419
|
}
|
|
220
420
|
};
|
|
421
|
+
case "open_github_app": return {
|
|
422
|
+
...base,
|
|
423
|
+
action: {
|
|
424
|
+
kind: "open_url",
|
|
425
|
+
url: resolveConnectUrl(act.url, resolveWebUrl(appUrl))
|
|
426
|
+
}
|
|
427
|
+
};
|
|
221
428
|
case "submit_credential": return {
|
|
222
429
|
...base,
|
|
223
430
|
action: {
|
|
@@ -337,17 +544,24 @@ async function runPrint({ appUrl, sessionToken, prompt, agentSelector, conversat
|
|
|
337
544
|
};
|
|
338
545
|
}
|
|
339
546
|
/**
|
|
340
|
-
* Turn a transport failure into an actionable CLI error.
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
547
|
+
* Turn a transport failure into an actionable CLI error. Two failure classes
|
|
548
|
+
* reach here, both of which otherwise leak a raw HTML/JSON error page to stdout:
|
|
549
|
+
*
|
|
550
|
+
* - A Cloudflare edge 5xx (502/504) on a long run. When a messageId is known
|
|
551
|
+
* and recoverable we point the user at `skydive messages get <messageId>`
|
|
552
|
+
* rather than a blind retry (a retry re-executes an agent that may have
|
|
553
|
+
* write access).
|
|
554
|
+
* - A 4xx from the send/stream endpoints (a validation 400 whose body is a raw
|
|
555
|
+
* Zod issues array, a billing/permission block, an expired session). These
|
|
556
|
+
* go through {@link sendErrorMessage} so the `-p` path prints the same plain,
|
|
557
|
+
* actionable copy the interactive TUI shows instead of an obtuse blob.
|
|
345
558
|
*/
|
|
346
559
|
function toPrintError(err, messageId) {
|
|
347
560
|
if (err instanceof HttpError && err.status >= 500) {
|
|
348
561
|
const recovery = messageId ? ` The run may still be completing server-side. Do NOT blindly retry (it would re-run the agent). Fetch the result with: skydive messages get ${messageId}` : "";
|
|
349
562
|
return /* @__PURE__ */ new Error(`The request to Skydive timed out at the edge (HTTP ${err.status}).${recovery}`);
|
|
350
563
|
}
|
|
564
|
+
if (err instanceof HttpError) return new Error(sendErrorMessage(err));
|
|
351
565
|
return err instanceof Error ? err : new Error(String(err));
|
|
352
566
|
}
|
|
353
567
|
/**
|
|
@@ -463,4 +677,4 @@ async function readStdin() {
|
|
|
463
677
|
}
|
|
464
678
|
|
|
465
679
|
//#endregion
|
|
466
|
-
export { runPrint as a, cardActionErrorMessage as c, reconcileMaskedInput as d, resolveConnectUrl as f, resolveAgent as i, parseExternalOauthConnectParams as l, messageGet as n, toPrintError as o, parseConnectCard as p, readStdin as r, MASK_CHAR as s, collectRunText as t, parseOauthConnectParams as u };
|
|
680
|
+
export { resolveAppUrl as A, getPromptHistoryPath as C, getStoredApiKeyId as D, getShareMachineDefault as E, saveConfig as F, saveSession as I, saveTheme as L, resolveManagementAuth as M, resolveSession as N, getStoredApiKeyWorkspaceName as O, resolveWebUrl as P, setLastSeenVersion as R, getLastSeenVersion as S, getSavedTheme as T, API_KEY_PREFIX as _, runPrint as a, deleteConfig as b, cardActionErrorMessage as c, reconcileMaskedInput as d, resolveConnectUrl as f, API_KEY_FAMILY_PREFIX as g, API_KEYS_URL as h, resolveAgent as i, resolveConfig as j, getUpdateCheckDisabled as k, parseExternalOauthConnectParams as l, specKeyFor as m, messageGet as n, toPrintError as o, parseConnectCard as p, readStdin as r, MASK_CHAR as s, collectRunText as t, parseOauthConnectParams as u, DEFAULT_API_URL as v, getReviewStateDir as w, getConfigPath as x, DEFAULT_APP_URL as y };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as printError } from "./output-
|
|
2
|
+
import { n as printError } from "./output-DYzzdXYV.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/chat/print-share.ts
|
|
5
5
|
/**
|
|
@@ -14,7 +14,7 @@ import { n as printError } from "./output-B4cW10Ph.mjs";
|
|
|
14
14
|
* the reply (and to --json).
|
|
15
15
|
*/
|
|
16
16
|
async function connectMachineShare({ appUrl, sessionToken, timeoutHint }) {
|
|
17
|
-
const { PortalClient } = await import("./client-
|
|
17
|
+
const { PortalClient } = await import("./client-DuwxEDG4.mjs");
|
|
18
18
|
let signalConnected;
|
|
19
19
|
const connected = new Promise((resolve) => {
|
|
20
20
|
signalConnected = resolve;
|
|
@@ -22,7 +22,7 @@ async function connectMachineShare({ appUrl, sessionToken, timeoutHint }) {
|
|
|
22
22
|
const machineShare = new PortalClient({
|
|
23
23
|
appUrl,
|
|
24
24
|
sessionToken,
|
|
25
|
-
|
|
25
|
+
resolveCwd: () => process.cwd(),
|
|
26
26
|
onState: (state) => {
|
|
27
27
|
if (state.status === "connected") signalConnected();
|
|
28
28
|
if (state.status === "error") console.error(`portal: connection error: ${state.error ?? "unknown"} — retrying`);
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createParser } from "eventsource-parser";
|
|
4
4
|
|
|
5
|
+
//#region src/chat/util.ts
|
|
6
|
+
/** Narrowing helper for the many `unknown` payloads the chat stream and
|
|
7
|
+
* tool inputs/outputs carry. A type predicate (not an `as` cast), so call
|
|
8
|
+
* sites can read properties without asserting. */
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
/** Best-effort message from an unknown thrown value. */
|
|
13
|
+
function errorMessage(err) {
|
|
14
|
+
return err instanceof Error ? err.message : String(err);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
5
18
|
//#region src/chat/api/rest.ts
|
|
6
19
|
var HttpError = class extends Error {
|
|
7
20
|
constructor(status, body) {
|
|
@@ -25,12 +38,76 @@ function errorDetail(err) {
|
|
|
25
38
|
}
|
|
26
39
|
return err instanceof Error ? err.message : String(err);
|
|
27
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The server's human-readable line out of an error response body, or null when
|
|
43
|
+
* the body carries none. Two shapes reach the CLI on a failed send:
|
|
44
|
+
*
|
|
45
|
+
* - `{ error: string }` — the chat route's own errors (a ChatClientError like
|
|
46
|
+
* "agent not found" / "attachment not found: <id>", the 429 "Too many
|
|
47
|
+
* requests", the generic "send failed"). Returned verbatim; these are
|
|
48
|
+
* written to be shown.
|
|
49
|
+
* - `{ success: false, error: { issues: [{ message }, …] } }` — a Zod request
|
|
50
|
+
* validation failure from `zValidator` (e.g. the message-too-long copy).
|
|
51
|
+
* Without this the CLI printed the whole issues array as a raw JSON blob,
|
|
52
|
+
* which is the "obtuse 400 with no recourse" a user sees. We lift the first
|
|
53
|
+
* issue's message, which the schema authors wrote for humans.
|
|
54
|
+
*
|
|
55
|
+
* Anything else (a Cloudflare/ALB HTML error page, an empty body) yields null
|
|
56
|
+
* so the caller can fall back to a status-based line.
|
|
57
|
+
*/
|
|
58
|
+
function serverErrorMessage(body) {
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(body);
|
|
62
|
+
} catch (_error) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(parsed)) return null;
|
|
66
|
+
if (typeof parsed.error === "string" && parsed.error.length > 0) return parsed.error;
|
|
67
|
+
if (isRecord(parsed.error) && Array.isArray(parsed.error.issues)) {
|
|
68
|
+
const first = parsed.error.issues.find((issue) => isRecord(issue) && typeof issue.message === "string" && !!issue.message);
|
|
69
|
+
if (first) return first.message;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A short, actionable line for a failed chat send, for both the interactive
|
|
75
|
+
* TUI error row and the headless `-p` path. The raw transport error is an
|
|
76
|
+
* `HTTP 400: {"success":false,"error":{"issues":[…]}}` blob that a user can do
|
|
77
|
+
* nothing with; this maps every send failure the server can produce to plain
|
|
78
|
+
* language and, where possible, a next step.
|
|
79
|
+
*
|
|
80
|
+
* Precedence: a message the SERVER wrote for humans always wins (validation
|
|
81
|
+
* copy, "agent not found", a billing/permission reason) — we only synthesize a
|
|
82
|
+
* line from the HTTP status when the body carried nothing usable. This keeps
|
|
83
|
+
* new server-side error copy flowing through without a CLI change, while never
|
|
84
|
+
* leaving the user staring at a bare status code.
|
|
85
|
+
*/
|
|
86
|
+
function sendErrorMessage(err) {
|
|
87
|
+
if (!(err instanceof HttpError)) return `Couldn't reach Skydive (${err instanceof Error ? err.message : String(err)}). Check your connection and try again.`;
|
|
88
|
+
const fromServer = serverErrorMessage(err.body);
|
|
89
|
+
switch (err.status) {
|
|
90
|
+
case 401: return "Your session has expired. Run `skydive auth login --web`, then send again.";
|
|
91
|
+
case 403: return fromServer ?? "You don't have permission to send to this agent in the current workspace.";
|
|
92
|
+
case 404: return fromServer ?? "This conversation or agent no longer exists.";
|
|
93
|
+
case 413: return fromServer ?? "That message is too large to send. Attach it as a file instead of pasting it inline.";
|
|
94
|
+
case 429: return "You're sending messages too quickly. Wait a moment and try again.";
|
|
95
|
+
default: break;
|
|
96
|
+
}
|
|
97
|
+
if (err.status >= 500) return `Skydive had a problem sending that (HTTP ${err.status}). Wait a moment and try again.`;
|
|
98
|
+
if (err.status === 400) {
|
|
99
|
+
if (fromServer && fromServer !== "send failed") return fromServer;
|
|
100
|
+
return "Skydive couldn't send that message. Try again in a moment.";
|
|
101
|
+
}
|
|
102
|
+
return fromServer ?? `Send failed (HTTP ${err.status}).`;
|
|
103
|
+
}
|
|
28
104
|
const MAX_STREAM_RECONNECTS = 5;
|
|
29
|
-
function createRestClient({ appUrl, sessionToken }) {
|
|
105
|
+
function createRestClient({ appUrl, sessionToken, workspaceId }) {
|
|
30
106
|
const baseHeaders = {
|
|
31
107
|
authorization: `Bearer ${sessionToken}`,
|
|
32
108
|
accept: "application/json"
|
|
33
109
|
};
|
|
110
|
+
if (workspaceId) baseHeaders["x-workspace-id"] = workspaceId;
|
|
34
111
|
async function get(path, schema) {
|
|
35
112
|
const res = await fetch(`${appUrl}${path}`, { headers: baseHeaders });
|
|
36
113
|
if (!res.ok) throw new HttpError(res.status, await res.text().catch(() => ""));
|
|
@@ -66,6 +143,7 @@ function createRestClient({ appUrl, sessionToken }) {
|
|
|
66
143
|
authorization: `Bearer ${sessionToken}`,
|
|
67
144
|
accept: "text/event-stream"
|
|
68
145
|
};
|
|
146
|
+
if (workspaceId) headers["x-workspace-id"] = workspaceId;
|
|
69
147
|
if (lastEventId) headers["last-event-id"] = lastEventId;
|
|
70
148
|
const res = await fetch(`${appUrl}${path}`, {
|
|
71
149
|
headers,
|
|
@@ -138,6 +216,17 @@ function createRestClient({ appUrl, sessionToken }) {
|
|
|
138
216
|
const { agent } = await post("/api/v1/agents", { name }, createAgentResponseSchema);
|
|
139
217
|
return agent;
|
|
140
218
|
},
|
|
219
|
+
getAgent: async ({ agentId }) => {
|
|
220
|
+
const { agent } = await get(`/api/v1/agents/${encodeURIComponent(agentId)}`, getAgentResponseSchema);
|
|
221
|
+
return {
|
|
222
|
+
id: agent.id,
|
|
223
|
+
name: agent.name
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
suggestAgentIdentity: async () => {
|
|
227
|
+
const { suggestion } = await get("/api/v1/agents/suggest", suggestAgentResponseSchema);
|
|
228
|
+
return suggestion;
|
|
229
|
+
},
|
|
141
230
|
getConversation: async ({ conversationId }) => {
|
|
142
231
|
const { conversation } = await get(`/api/v1/conversations/${encodeURIComponent(conversationId)}`, getConversationResponseSchema);
|
|
143
232
|
return conversation;
|
|
@@ -150,7 +239,8 @@ function createRestClient({ appUrl, sessionToken }) {
|
|
|
150
239
|
const { agent } = await post(`/api/v1/agents/${encodeURIComponent(agentId)}`, { model }, updateAgentResponseSchema, "PATCH");
|
|
151
240
|
return { model: agent.model ?? null };
|
|
152
241
|
},
|
|
153
|
-
listConversations: async ({ agentId, limit, channels, onPage }) => {
|
|
242
|
+
listConversations: async ({ agentId, limit, channels, archived, query, onPage }) => {
|
|
243
|
+
const trimmedQuery = query?.trim();
|
|
154
244
|
const all = [];
|
|
155
245
|
const maxConversations = limit ?? 5e3;
|
|
156
246
|
let cursor;
|
|
@@ -162,6 +252,8 @@ function createRestClient({ appUrl, sessionToken }) {
|
|
|
162
252
|
});
|
|
163
253
|
params.set("limit", String(Math.min(remaining, 100)));
|
|
164
254
|
if (cursor) params.set("cursor", cursor);
|
|
255
|
+
if (archived) params.set("archived", archived);
|
|
256
|
+
if (trimmedQuery) params.set("query", trimmedQuery);
|
|
165
257
|
for (const channel of channels ?? []) params.append("channels", channel);
|
|
166
258
|
const page = await get(`/api/v1/conversations?${params.toString()}`, listConversationsResponseSchema);
|
|
167
259
|
all.push(...page.conversations);
|
|
@@ -222,9 +314,19 @@ function createRestClient({ appUrl, sessionToken }) {
|
|
|
222
314
|
sizeBytes: finalized.sizeBytes ?? size
|
|
223
315
|
};
|
|
224
316
|
},
|
|
317
|
+
forkConversation: async ({ conversationId }) => {
|
|
318
|
+
const { conversation } = await post(`/api/v1/conversations/${encodeURIComponent(conversationId)}/fork`, {}, forkConversationResponseSchema);
|
|
319
|
+
return conversation;
|
|
320
|
+
},
|
|
321
|
+
compactConversation: async ({ conversationId }) => {
|
|
322
|
+
await post(`/api/v1/conversations/${encodeURIComponent(conversationId)}/compact`, {}, z.object({ status: z.string() }));
|
|
323
|
+
},
|
|
225
324
|
deleteConversation: async ({ conversationId }) => {
|
|
226
325
|
await del(`/api/v1/conversations/${encodeURIComponent(conversationId)}`);
|
|
227
326
|
},
|
|
327
|
+
setConversationArchived: async ({ conversationId, archived }) => {
|
|
328
|
+
await post(`/api/v1/conversations/${encodeURIComponent(conversationId)}/archive`, { archived }, z.object({ archived: z.boolean() }));
|
|
329
|
+
},
|
|
228
330
|
sendMessage: async ({ clientSurface, ...input }) => post("/api/v1/chat/send", {
|
|
229
331
|
...input,
|
|
230
332
|
clientSurface
|
|
@@ -275,7 +377,8 @@ function createRestClient({ appUrl, sessionToken }) {
|
|
|
275
377
|
const res = await fetch(`${appUrl}/api/v1/chat/conversations/${encodeURIComponent(conversationId)}/stream`, {
|
|
276
378
|
headers: {
|
|
277
379
|
authorization: `Bearer ${sessionToken}`,
|
|
278
|
-
accept: "text/event-stream"
|
|
380
|
+
accept: "text/event-stream",
|
|
381
|
+
...workspaceId ? { "x-workspace-id": workspaceId } : {}
|
|
279
382
|
},
|
|
280
383
|
signal
|
|
281
384
|
});
|
|
@@ -343,6 +446,18 @@ const listAgentsResponseSchema = z.object({
|
|
|
343
446
|
totalCount: z.number().nullable().optional()
|
|
344
447
|
});
|
|
345
448
|
const createAgentResponseSchema = z.object({ agent: agentSummarySchema });
|
|
449
|
+
const getAgentResponseSchema = z.object({ agent: z.object({
|
|
450
|
+
id: z.string(),
|
|
451
|
+
name: z.string()
|
|
452
|
+
}).passthrough() });
|
|
453
|
+
const agentSuggestionSchema = z.object({ name: z.string() });
|
|
454
|
+
const suggestAgentResponseSchema = z.object({ suggestion: agentSuggestionSchema.nullable() });
|
|
455
|
+
const conversationAgentSchema = z.object({
|
|
456
|
+
id: z.string().uuid(),
|
|
457
|
+
name: z.string(),
|
|
458
|
+
slug: z.string().nullable().optional(),
|
|
459
|
+
title: z.string().nullable().optional()
|
|
460
|
+
});
|
|
346
461
|
const conversationSummarySchema = z.object({
|
|
347
462
|
id: z.string().uuid(),
|
|
348
463
|
title: z.string().nullable(),
|
|
@@ -351,20 +466,24 @@ const conversationSummarySchema = z.object({
|
|
|
351
466
|
preview: z.string().nullable(),
|
|
352
467
|
channel: z.string().nullable(),
|
|
353
468
|
channelLabel: z.string().nullable(),
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
slug: z.string().nullable().optional(),
|
|
358
|
-
title: z.string().nullable().optional()
|
|
359
|
-
})
|
|
469
|
+
viewerArchivedAt: z.string().nullable().optional(),
|
|
470
|
+
agent: conversationAgentSchema,
|
|
471
|
+
agents: z.array(conversationAgentSchema).optional()
|
|
360
472
|
});
|
|
361
473
|
const conversationDetailSchema = z.object({
|
|
362
474
|
id: z.string().uuid(),
|
|
363
475
|
title: z.string().nullable(),
|
|
364
476
|
agentId: z.string().uuid(),
|
|
365
477
|
createdAt: z.string(),
|
|
366
|
-
updatedAt: z.string()
|
|
478
|
+
updatedAt: z.string(),
|
|
479
|
+
channel: z.string().nullable(),
|
|
480
|
+
channelLabel: z.string().nullable()
|
|
481
|
+
});
|
|
482
|
+
const conversationTitleSchema = z.object({
|
|
483
|
+
id: z.string().uuid(),
|
|
484
|
+
title: z.string().nullable()
|
|
367
485
|
});
|
|
486
|
+
const forkConversationResponseSchema = z.object({ conversation: conversationTitleSchema });
|
|
368
487
|
const getConversationResponseSchema = z.object({ conversation: conversationDetailSchema });
|
|
369
488
|
const listConversationsResponseSchema = z.object({
|
|
370
489
|
conversations: z.array(conversationSummarySchema),
|
|
@@ -444,14 +563,26 @@ const runStreamEventSchema = z.union([z.object({
|
|
|
444
563
|
error: z.string().nullish()
|
|
445
564
|
})]);
|
|
446
565
|
const streamErrorSchema = z.object({ error: z.string() });
|
|
447
|
-
const conversationStreamEventSchema = z.discriminatedUnion("kind", [
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
566
|
+
const conversationStreamEventSchema = z.discriminatedUnion("kind", [
|
|
567
|
+
z.object({
|
|
568
|
+
kind: z.literal("conversation"),
|
|
569
|
+
id: z.string(),
|
|
570
|
+
title: z.string().nullable()
|
|
571
|
+
}),
|
|
572
|
+
z.object({
|
|
573
|
+
kind: z.literal("run"),
|
|
574
|
+
runId: z.string(),
|
|
575
|
+
createdAt: z.string().nullish()
|
|
576
|
+
}),
|
|
577
|
+
z.object({
|
|
578
|
+
kind: z.literal("compaction"),
|
|
579
|
+
status: z.enum([
|
|
580
|
+
"started",
|
|
581
|
+
"completed",
|
|
582
|
+
"failed"
|
|
583
|
+
])
|
|
584
|
+
})
|
|
585
|
+
]);
|
|
455
586
|
|
|
456
587
|
//#endregion
|
|
457
|
-
export { createRestClient as n, errorDetail as r, HttpError as t };
|
|
588
|
+
export { errorMessage as a, sendErrorMessage as i, createRestClient as n, isRecord as o, errorDetail as r, HttpError as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skydive-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Skydive CLI — cloud agents from the command line",
|
|
5
5
|
"homepage": "https://skydive.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"test:ci": "vitest run --coverage --coverage.reporter=lcovonly --reporter=default --reporter=github-actions --minWorkers=1 --maxWorkers=2 --passWithNoTests && yarn test:tui",
|
|
26
26
|
"test:tui": "bun test .tui.test",
|
|
27
27
|
"render:frames": "bun scripts/render-frames.tsx",
|
|
28
|
+
"render:send-errors": "bun scripts/render-send-errors.tsx",
|
|
28
29
|
"typecheck": "tsgo --noEmit"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"open": "^10.1.0",
|
|
39
40
|
"react": "^19.0.0",
|
|
40
41
|
"react-devtools-core": "^7.0.1",
|
|
42
|
+
"safe-stable-stringify": "^2.3.1",
|
|
41
43
|
"semver": "^7.7.4",
|
|
42
44
|
"web-tree-sitter": "0.25.10",
|
|
43
45
|
"ws": "^8.21.0",
|