skydive-cli 0.1.0 → 0.2.0-beta.422
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 +68 -0
- package/README.md +271 -28
- package/dist/js/api-CDTKq_5Q.mjs +145 -0
- package/dist/js/bin.mjs +1752 -1359
- package/dist/js/boot-CQLDUeZ9.mjs +9827 -0
- package/dist/js/client-B3ZhhF7e.mjs +366 -0
- package/dist/js/client-CpEvH2Pq.mjs +169 -0
- package/dist/js/client-D6NAkL9e.mjs +6 -0
- package/dist/js/output-B4cW10Ph.mjs +27 -0
- package/dist/js/print-B6AO13SA.mjs +5 -0
- package/dist/js/print-DausK_KZ.mjs +484 -0
- package/dist/js/print-share-D7OSxvE2.mjs +41 -0
- package/dist/js/raw-pty-2_VA1kw_.mjs +5 -0
- package/dist/js/raw-pty-GAvxm2ol.mjs +99 -0
- package/dist/js/rest-COkLEZOB.mjs +4 -0
- package/dist/js/{print-BHbFMxQv.mjs → rest-CamHVOce.mjs} +173 -158
- package/dist/js/theme-CuQhvqzN.mjs +990 -0
- package/dist/js/util-CeisaZVY.mjs +15 -0
- package/package.json +20 -9
- package/dist/js/boot-ChlVx-ts.mjs +0 -5525
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as createRestClient, t as HttpError } from "./rest-CamHVOce.mjs";
|
|
3
|
+
import { n as isRecord, t as errorMessage } from "./util-CeisaZVY.mjs";
|
|
4
|
+
import stableStringify from "safe-stable-stringify";
|
|
5
|
+
|
|
6
|
+
//#region src/chat/tui/chat/card.ts
|
|
7
|
+
const urlActionKinds = [
|
|
8
|
+
"open_oauth",
|
|
9
|
+
"open_external_oauth",
|
|
10
|
+
"open_github_app",
|
|
11
|
+
"submit_credential"
|
|
12
|
+
];
|
|
13
|
+
function isUrlActionKind(value) {
|
|
14
|
+
return typeof value === "string" && urlActionKinds.includes(value);
|
|
15
|
+
}
|
|
16
|
+
function optionalString(value) {
|
|
17
|
+
return typeof value === "string" && value ? value : null;
|
|
18
|
+
}
|
|
19
|
+
function bindStateKey(props) {
|
|
20
|
+
const value = props.value;
|
|
21
|
+
if (!isRecord(value)) return null;
|
|
22
|
+
const pointer = value.$bindState;
|
|
23
|
+
if (typeof pointer !== "string") return null;
|
|
24
|
+
return pointer.startsWith("/") ? pointer.slice(1) : pointer;
|
|
25
|
+
}
|
|
26
|
+
function parseButton(element) {
|
|
27
|
+
const props = isRecord(element.props) ? element.props : {};
|
|
28
|
+
const label = optionalString(props.label) ?? "Connect";
|
|
29
|
+
const on = isRecord(element.on) ? element.on : null;
|
|
30
|
+
const press = on && isRecord(on.press) ? on.press : null;
|
|
31
|
+
if (!press) return null;
|
|
32
|
+
const params = isRecord(press.params) ? press.params : {};
|
|
33
|
+
const primary = props.variant === "primary";
|
|
34
|
+
if (press.action === "approve_portal_access") {
|
|
35
|
+
const agentId = params.agentId;
|
|
36
|
+
if (typeof agentId !== "string" || !agentId) return null;
|
|
37
|
+
return {
|
|
38
|
+
label,
|
|
39
|
+
action: {
|
|
40
|
+
kind: "grant_portal",
|
|
41
|
+
agentId,
|
|
42
|
+
deviceId: typeof params.deviceId === "string" && params.deviceId ? params.deviceId : null
|
|
43
|
+
},
|
|
44
|
+
primary
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (!isUrlActionKind(press.action)) return null;
|
|
48
|
+
const url = params.url;
|
|
49
|
+
if (typeof url !== "string" || !url) return null;
|
|
50
|
+
return {
|
|
51
|
+
label,
|
|
52
|
+
action: {
|
|
53
|
+
kind: press.action,
|
|
54
|
+
url
|
|
55
|
+
},
|
|
56
|
+
primary
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Content identity for a card's spec, which is what separates a re-delivery of
|
|
61
|
+
* one card from two distinct cards: the same card reaches us twice (live on the
|
|
62
|
+
* run stream and again as the persisted message part when history is loaded for
|
|
63
|
+
* an in-flight run), while a single tool call can legitimately post SEVERAL
|
|
64
|
+
* different cards — `platform` subprocesses chained in one bash command all
|
|
65
|
+
* read the same TOOL_CALL_ID, so `toolCallId` alone identifies neither. Applies
|
|
66
|
+
* the same identity rule as the web client (web/src/features/chat-v2/turn-segments.ts).
|
|
67
|
+
*
|
|
68
|
+
* The serialization has to be key-order-independent, because the persisted copy
|
|
69
|
+
* of a spec comes back through a jsonb round-trip that can reorder keys and
|
|
70
|
+
* order-sensitive `JSON.stringify` would read that as a different card.
|
|
71
|
+
* `safe-stable-stringify` sorts keys and tolerates cycles.
|
|
72
|
+
*/
|
|
73
|
+
function specKeyFor(spec) {
|
|
74
|
+
return stableStringify(spec) ?? crypto.randomUUID();
|
|
75
|
+
}
|
|
76
|
+
function parseConnectCard(spec) {
|
|
77
|
+
if (!isRecord(spec)) return null;
|
|
78
|
+
const { root, elements } = spec;
|
|
79
|
+
if (typeof root !== "string" || !isRecord(elements)) return null;
|
|
80
|
+
const rootEl = elements[root];
|
|
81
|
+
if (!isRecord(rootEl) || rootEl.type !== "Card") return null;
|
|
82
|
+
const rootProps = isRecord(rootEl.props) ? rootEl.props : {};
|
|
83
|
+
const title = optionalString(rootProps.title);
|
|
84
|
+
if (!title) return null;
|
|
85
|
+
const fields = [];
|
|
86
|
+
const buttons = [];
|
|
87
|
+
const children = Array.isArray(rootEl.children) ? rootEl.children : [];
|
|
88
|
+
for (const childId of children) {
|
|
89
|
+
if (typeof childId !== "string") continue;
|
|
90
|
+
const el = elements[childId];
|
|
91
|
+
if (!isRecord(el)) continue;
|
|
92
|
+
if (el.type === "TextInput" || el.type === "SecretInput") {
|
|
93
|
+
const props = isRecord(el.props) ? el.props : {};
|
|
94
|
+
const key = bindStateKey(props);
|
|
95
|
+
if (!key) continue;
|
|
96
|
+
fields.push({
|
|
97
|
+
key,
|
|
98
|
+
label: optionalString(props.label) ?? key,
|
|
99
|
+
placeholder: optionalString(props.placeholder),
|
|
100
|
+
secret: el.type === "SecretInput"
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (el.type === "Button") {
|
|
105
|
+
const button = parseButton(el);
|
|
106
|
+
if (button) buttons.push(button);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const preferred = buttons.find((b) => b.primary) ?? buttons[0] ?? null;
|
|
110
|
+
return {
|
|
111
|
+
title,
|
|
112
|
+
subtitle: optionalString(rootProps.subtitle),
|
|
113
|
+
description: optionalString(rootProps.description),
|
|
114
|
+
fields,
|
|
115
|
+
button: preferred ? {
|
|
116
|
+
label: preferred.label,
|
|
117
|
+
action: preferred.action
|
|
118
|
+
} : null,
|
|
119
|
+
state: isRecord(spec.state) ? spec.state : {}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/chat/tui/chat/card-actions.ts
|
|
125
|
+
function safeUrl(url) {
|
|
126
|
+
try {
|
|
127
|
+
return new URL(url, "http://localhost");
|
|
128
|
+
} catch (_error) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a connect URL to an absolute one the OS can open in a browser.
|
|
134
|
+
*
|
|
135
|
+
* The server builds lazy connect links as server-relative paths (e.g.
|
|
136
|
+
* `/api/v1/oauth/start/slack?connect_session_token=...`). The web client
|
|
137
|
+
* resolves these against its own origin implicitly; the TUI runs outside a
|
|
138
|
+
* browser, so `open()` on a scheme-less path is a silent no-op — the browser
|
|
139
|
+
* never launches and the connect card sits in `launched` forever (the Slack
|
|
140
|
+
* channel-connect deadlock). Resolve against `appUrl` first, mirroring the
|
|
141
|
+
* `open_github_app` / `fulfillCredential` branches, so both the browser we
|
|
142
|
+
* launch and the URL shown on the card are absolute. An already-absolute URL
|
|
143
|
+
* passes through unchanged; if `appUrl` is missing we return the input as-is.
|
|
144
|
+
*/
|
|
145
|
+
function resolveConnectUrl(url, appUrl) {
|
|
146
|
+
if (!appUrl) return url;
|
|
147
|
+
try {
|
|
148
|
+
return new URL(url, appUrl).toString();
|
|
149
|
+
} catch (_error) {
|
|
150
|
+
return url;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function parseOauthConnectParams(url) {
|
|
154
|
+
const parsed = safeUrl(url);
|
|
155
|
+
if (!parsed) return null;
|
|
156
|
+
const integrationKey = parsed.searchParams.get("integration");
|
|
157
|
+
const agentId = parsed.searchParams.get("agent_id");
|
|
158
|
+
const authConfigId = parsed.searchParams.get("auth_config_id");
|
|
159
|
+
const conversationId = parsed.searchParams.get("conversation_id");
|
|
160
|
+
if (!integrationKey || !agentId || !authConfigId || !conversationId) return null;
|
|
161
|
+
return {
|
|
162
|
+
integrationKey,
|
|
163
|
+
agentId,
|
|
164
|
+
authConfigId,
|
|
165
|
+
conversationId,
|
|
166
|
+
scopes: parsed.searchParams.get("scopes")
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function parseExternalOauthConnectParams(url) {
|
|
170
|
+
const parsed = safeUrl(url);
|
|
171
|
+
if (!parsed) return null;
|
|
172
|
+
const agentId = parsed.searchParams.get("agent_id");
|
|
173
|
+
const conversationId = parsed.searchParams.get("conversation_id");
|
|
174
|
+
const serverUrl = parsed.searchParams.get("server_url");
|
|
175
|
+
if (!agentId || !conversationId || !serverUrl) return null;
|
|
176
|
+
return {
|
|
177
|
+
agentId,
|
|
178
|
+
conversationId,
|
|
179
|
+
serverUrl
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A short human line for a failed card action. Fulfill/connect endpoints
|
|
184
|
+
* return `{ error: string }` bodies (e.g. "already fulfilled") — prefer that
|
|
185
|
+
* over the generic HttpError message.
|
|
186
|
+
*/
|
|
187
|
+
function cardActionErrorMessage(err) {
|
|
188
|
+
if (err instanceof HttpError) {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(err.body);
|
|
191
|
+
if (isRecord(parsed) && typeof parsed.error === "string") return parsed.error;
|
|
192
|
+
} catch (_error) {}
|
|
193
|
+
return `request failed (HTTP ${err.status})`;
|
|
194
|
+
}
|
|
195
|
+
return errorMessage(err);
|
|
196
|
+
}
|
|
197
|
+
const MASK_CHAR = "•";
|
|
198
|
+
/**
|
|
199
|
+
* Recover the real secret from the masked input's displayed text. The input
|
|
200
|
+
* is controlled: after every edit we render bullets, which forces the cursor
|
|
201
|
+
* to the end, so the next edit is always a tail edit — the displayed text is
|
|
202
|
+
* some prefix of the old mask (kept characters) followed by newly typed or
|
|
203
|
+
* pasted plaintext. Characters beyond the retained bullets are the new tail.
|
|
204
|
+
*/
|
|
205
|
+
function reconcileMaskedInput(previousValue, displayed) {
|
|
206
|
+
let kept = 0;
|
|
207
|
+
while (kept < displayed.length && kept < previousValue.length && displayed[kept] === MASK_CHAR) kept++;
|
|
208
|
+
return previousValue.slice(0, kept) + displayed.slice(kept);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/chat/connect-cards.ts
|
|
213
|
+
/**
|
|
214
|
+
* Turn a parsed ConnectCard into the headless summary, resolving any relative
|
|
215
|
+
* connect URL against the app origin so the emitted URL is directly openable.
|
|
216
|
+
*/
|
|
217
|
+
function summarizeConnectCard(card, appUrl) {
|
|
218
|
+
const base = {
|
|
219
|
+
title: card.title,
|
|
220
|
+
subtitle: card.subtitle,
|
|
221
|
+
description: card.description
|
|
222
|
+
};
|
|
223
|
+
const button = card.button;
|
|
224
|
+
if (!button) return {
|
|
225
|
+
...base,
|
|
226
|
+
action: { kind: "unsupported" }
|
|
227
|
+
};
|
|
228
|
+
const act = button.action;
|
|
229
|
+
switch (act.kind) {
|
|
230
|
+
case "open_oauth":
|
|
231
|
+
case "open_external_oauth":
|
|
232
|
+
case "open_github_app": return {
|
|
233
|
+
...base,
|
|
234
|
+
action: {
|
|
235
|
+
kind: "open_url",
|
|
236
|
+
url: resolveConnectUrl(act.url, appUrl)
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
case "submit_credential": return {
|
|
240
|
+
...base,
|
|
241
|
+
action: {
|
|
242
|
+
kind: "submit_credential",
|
|
243
|
+
url: resolveConnectUrl(act.url, appUrl),
|
|
244
|
+
fields: card.fields.map((f) => f.label)
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
case "grant_portal": return {
|
|
248
|
+
...base,
|
|
249
|
+
action: {
|
|
250
|
+
kind: "approve_portal",
|
|
251
|
+
agentId: act.agentId
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
default: return {
|
|
255
|
+
...base,
|
|
256
|
+
action: { kind: "unsupported" }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Parse a `data-anyone-render-spec` stream chunk into a connect-card summary,
|
|
262
|
+
* or null if the chunk isn't a connect card (other render specs — training,
|
|
263
|
+
* deep-learn, compute-request — parse to null, same as the TUI).
|
|
264
|
+
*/
|
|
265
|
+
function connectCardFromChunk(chunk, appUrl) {
|
|
266
|
+
if (chunk["type"] !== "data-anyone-render-spec") return null;
|
|
267
|
+
const data = chunk["data"];
|
|
268
|
+
if (!isRecord(data)) return null;
|
|
269
|
+
const card = parseConnectCard(data["spec"]);
|
|
270
|
+
if (!card) return null;
|
|
271
|
+
return summarizeConnectCard(card, appUrl);
|
|
272
|
+
}
|
|
273
|
+
/** Render a connect-card summary as a human-readable action block. */
|
|
274
|
+
function formatConnectCard(card) {
|
|
275
|
+
const lines = [];
|
|
276
|
+
lines.push(`\n[action needed] ${card.title}`);
|
|
277
|
+
if (card.subtitle) lines.push(card.subtitle);
|
|
278
|
+
if (card.description) lines.push(card.description);
|
|
279
|
+
switch (card.action.kind) {
|
|
280
|
+
case "open_url":
|
|
281
|
+
lines.push(`Open to continue: ${card.action.url}`);
|
|
282
|
+
break;
|
|
283
|
+
case "submit_credential":
|
|
284
|
+
lines.push(`Provide credential (${card.action.fields.join(", ") || "value"}) at: ${card.action.url}`);
|
|
285
|
+
break;
|
|
286
|
+
case "approve_portal":
|
|
287
|
+
lines.push(`Approve local-machine access for agent ${card.action.agentId} in the TUI or web app.`);
|
|
288
|
+
break;
|
|
289
|
+
case "unsupported":
|
|
290
|
+
lines.push("Open this conversation in the web app to continue.");
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
return lines.join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/chat/print.ts
|
|
298
|
+
/**
|
|
299
|
+
* Non-interactive chat, à la `claude -p`. Sends a single prompt to an
|
|
300
|
+
* agent, streams the run, and prints the assistant's reply to stdout
|
|
301
|
+
* before exiting. No OpenTUI, no Bun requirement — this rides the same
|
|
302
|
+
* Node-friendly REST client the TUI uses, so it runs anywhere the
|
|
303
|
+
* management commands do (CI, pipes, scripts).
|
|
304
|
+
*
|
|
305
|
+
* Resolution rules kept deliberately strict because there's no human to
|
|
306
|
+
* disambiguate: an `--agent` selector must match exactly one agent, and
|
|
307
|
+
* when it's omitted we only auto-pick if the account has exactly one.
|
|
308
|
+
*/
|
|
309
|
+
async function runPrint({ appUrl, sessionToken, prompt, agentSelector, conversationId, json, machineShare, grantTargetAgent }) {
|
|
310
|
+
const client = createRestClient({
|
|
311
|
+
appUrl,
|
|
312
|
+
sessionToken
|
|
313
|
+
});
|
|
314
|
+
const agent = resolveAgent(await client.listAgents({
|
|
315
|
+
scope: "org",
|
|
316
|
+
onPage: null
|
|
317
|
+
}), agentSelector);
|
|
318
|
+
if (machineShare) {
|
|
319
|
+
if (!machineShare.isGranted(agent.id) && grantTargetAgent) await machineShare.grantAgent(agent.id);
|
|
320
|
+
if (machineShare.isGranted(agent.id)) console.error(`portal: shared this machine with ${agent.name} for this run (grant persists until revoked)`);
|
|
321
|
+
else console.error(`portal: this machine is shared (shareMachineDefault), but ${agent.name} has no grant — approve its request, or run \`skydive portal grant --agent ${agent.name}\`.`);
|
|
322
|
+
}
|
|
323
|
+
let send;
|
|
324
|
+
try {
|
|
325
|
+
send = await client.sendMessage({
|
|
326
|
+
agentId: agent.id,
|
|
327
|
+
conversationId,
|
|
328
|
+
content: prompt,
|
|
329
|
+
attachmentIds: [],
|
|
330
|
+
clientSurface: "cli"
|
|
331
|
+
});
|
|
332
|
+
} catch (err) {
|
|
333
|
+
throw toPrintError(err);
|
|
334
|
+
}
|
|
335
|
+
const { text, connectCards } = await collectRunText({
|
|
336
|
+
client,
|
|
337
|
+
appUrl,
|
|
338
|
+
target: {
|
|
339
|
+
kind: "run",
|
|
340
|
+
id: send.runId
|
|
341
|
+
},
|
|
342
|
+
onText: json ? null : (delta) => process.stdout.write(delta),
|
|
343
|
+
messageIdForHint: send.messageId ?? null
|
|
344
|
+
});
|
|
345
|
+
if (!json && text && !text.endsWith("\n")) process.stdout.write("\n");
|
|
346
|
+
if (!json) for (const card of connectCards) process.stdout.write(`${formatConnectCard(card)}\n`);
|
|
347
|
+
return {
|
|
348
|
+
agentId: agent.id,
|
|
349
|
+
agentName: agent.name,
|
|
350
|
+
conversationId: send.conversationId,
|
|
351
|
+
isNewConversation: send.isNewConversation,
|
|
352
|
+
messageId: send.messageId ?? null,
|
|
353
|
+
text,
|
|
354
|
+
connectCards
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Turn a transport failure into an actionable CLI error. A Cloudflare edge
|
|
359
|
+
* 5xx (502/504) on a long run otherwise leaks a raw HTML/JSON error page to
|
|
360
|
+
* stdout, which is impossible to act on. When a messageId is known and
|
|
361
|
+
* recoverable we point the user at `skydive messages get <messageId>` rather
|
|
362
|
+
* than a blind retry (a retry re-executes an agent that may have write access).
|
|
363
|
+
*/
|
|
364
|
+
function toPrintError(err, messageId) {
|
|
365
|
+
if (err instanceof HttpError && err.status >= 500) {
|
|
366
|
+
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}` : "";
|
|
367
|
+
return /* @__PURE__ */ new Error(`The request to Skydive timed out at the edge (HTTP ${err.status}).${recovery}`);
|
|
368
|
+
}
|
|
369
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Stream a run to completion, folding text-delta chunks into the reply and
|
|
373
|
+
* collecting any connect cards (OAuth / MCP / credential requests) the run
|
|
374
|
+
* posts. Shared by `chat -p` (streaming the run it just created) and `messages
|
|
375
|
+
* get` (re-attaching by message id — the server replays a finished run from its
|
|
376
|
+
* persisted log, so this works whether the run is live or already done).
|
|
377
|
+
*/
|
|
378
|
+
async function collectRunText({ client, appUrl, target, onText, messageIdForHint }) {
|
|
379
|
+
let text = "";
|
|
380
|
+
const controller = new AbortController();
|
|
381
|
+
let streamError = null;
|
|
382
|
+
const connectCards = [];
|
|
383
|
+
const onEvent = (event) => {
|
|
384
|
+
if (event.kind === "finished") {
|
|
385
|
+
if (event.error) streamError = event.error;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const chunk = event.chunk;
|
|
389
|
+
if (chunk["type"] === "text-delta") {
|
|
390
|
+
const delta = typeof chunk["delta"] === "string" ? chunk["delta"] : typeof chunk["text"] === "string" ? chunk["text"] : "";
|
|
391
|
+
if (delta) {
|
|
392
|
+
text += delta;
|
|
393
|
+
if (onText) onText(delta);
|
|
394
|
+
}
|
|
395
|
+
} else if (chunk["type"] === "error") streamError = typeof chunk["errorText"] === "string" ? chunk["errorText"] : "unknown error";
|
|
396
|
+
else {
|
|
397
|
+
const card = connectCardFromChunk(chunk, appUrl);
|
|
398
|
+
if (card) connectCards.push(card);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
try {
|
|
402
|
+
if (target.kind === "message") await client.streamMessage({
|
|
403
|
+
messageId: target.id,
|
|
404
|
+
signal: controller.signal,
|
|
405
|
+
onEvent
|
|
406
|
+
});
|
|
407
|
+
else await client.streamRun({
|
|
408
|
+
runId: target.id,
|
|
409
|
+
signal: controller.signal,
|
|
410
|
+
onEvent
|
|
411
|
+
});
|
|
412
|
+
} catch (err) {
|
|
413
|
+
throw toPrintError(err, messageIdForHint);
|
|
414
|
+
}
|
|
415
|
+
if (streamError) throw new Error(streamError);
|
|
416
|
+
return {
|
|
417
|
+
text,
|
|
418
|
+
connectCards
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Re-attach to an exchange by message id and print its reply. Backs `skydive
|
|
423
|
+
* messages get <messageId>` — the recovery path when a `chat -p` stream dropped
|
|
424
|
+
* at the edge after the message was accepted. The server resolves the run
|
|
425
|
+
* behind the message and replays it from its persisted event log, so this
|
|
426
|
+
* returns the full reply whether the run is still live or already done.
|
|
427
|
+
*/
|
|
428
|
+
async function messageGet({ appUrl, sessionToken, messageId, json }) {
|
|
429
|
+
const { text, connectCards } = await collectRunText({
|
|
430
|
+
client: createRestClient({
|
|
431
|
+
appUrl,
|
|
432
|
+
sessionToken
|
|
433
|
+
}),
|
|
434
|
+
appUrl,
|
|
435
|
+
target: {
|
|
436
|
+
kind: "message",
|
|
437
|
+
id: messageId
|
|
438
|
+
},
|
|
439
|
+
onText: json ? null : (delta) => process.stdout.write(delta),
|
|
440
|
+
messageIdForHint: null
|
|
441
|
+
});
|
|
442
|
+
if (!json && text && !text.endsWith("\n")) process.stdout.write("\n");
|
|
443
|
+
if (!json) for (const card of connectCards) process.stdout.write(`${formatConnectCard(card)}\n`);
|
|
444
|
+
return {
|
|
445
|
+
messageId,
|
|
446
|
+
text,
|
|
447
|
+
connectCards
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Pick the target agent. With no selector, auto-pick only when the
|
|
452
|
+
* account has exactly one agent; otherwise the user must name one (there's
|
|
453
|
+
* no picker in non-interactive mode). A selector matches by id first, then
|
|
454
|
+
* a unique case-insensitive slug/name; ambiguous or missing matches throw
|
|
455
|
+
* with the candidate list so the caller knows what to pass.
|
|
456
|
+
*/
|
|
457
|
+
function resolveAgent(agents, selector) {
|
|
458
|
+
if (!selector) {
|
|
459
|
+
const [only, ...rest] = agents;
|
|
460
|
+
if (!only) throw new Error("No agents on this account.");
|
|
461
|
+
if (rest.length === 0) return only;
|
|
462
|
+
throw new Error(`Multiple agents on this account — pass --agent <id|slug|name>. Candidates:\n${formatCandidates(agents)}`);
|
|
463
|
+
}
|
|
464
|
+
const byId = agents.find((a) => a.id === selector);
|
|
465
|
+
if (byId) return byId;
|
|
466
|
+
const needle = selector.toLowerCase();
|
|
467
|
+
const matches = agents.filter((a) => a.slug && a.slug.toLowerCase() === needle || a.name.toLowerCase() === needle);
|
|
468
|
+
const [firstMatch, ...restMatches] = matches;
|
|
469
|
+
if (firstMatch && restMatches.length === 0) return firstMatch;
|
|
470
|
+
if (restMatches.length > 0) throw new Error(`Multiple agents match "${selector}" — pass the id instead. Candidates:\n${formatCandidates(matches)}`);
|
|
471
|
+
throw new Error(`No agent matches "${selector}". Candidates:\n${formatCandidates(agents)}`);
|
|
472
|
+
}
|
|
473
|
+
function formatCandidates(agents) {
|
|
474
|
+
return agents.slice(0, 25).map((a) => ` ${a.id} ${a.slug ?? a.name}`).join("\n");
|
|
475
|
+
}
|
|
476
|
+
/** Read all of stdin as UTF-8. Used when `-p` is passed with no value. */
|
|
477
|
+
async function readStdin() {
|
|
478
|
+
const chunks = [];
|
|
479
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
480
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
//#endregion
|
|
484
|
+
export { runPrint as a, cardActionErrorMessage as c, reconcileMaskedInput as d, resolveConnectUrl as f, resolveAgent as i, 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 };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as printError } from "./output-B4cW10Ph.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/chat/print-share.ts
|
|
5
|
+
/**
|
|
6
|
+
* Machine sharing for a scripted one-shot run (`chat -p --share-machine`,
|
|
7
|
+
* `import -p`): connect the portal from launch and hand back the client so
|
|
8
|
+
* the caller can pass it to `runPrint` (which grants the resolved target
|
|
9
|
+
* agent) and dispose it when the run ends.
|
|
10
|
+
*
|
|
11
|
+
* The client retries forever by design; a scripted one-shot needs a bounded
|
|
12
|
+
* failure instead of a silent hang, so a 30s connect timeout exits the
|
|
13
|
+
* process with guidance. Portal telemetry goes to stderr — stdout belongs to
|
|
14
|
+
* the reply (and to --json).
|
|
15
|
+
*/
|
|
16
|
+
async function connectMachineShare({ appUrl, sessionToken, timeoutHint }) {
|
|
17
|
+
const { PortalClient } = await import("./client-D6NAkL9e.mjs");
|
|
18
|
+
let signalConnected;
|
|
19
|
+
const connected = new Promise((resolve) => {
|
|
20
|
+
signalConnected = resolve;
|
|
21
|
+
});
|
|
22
|
+
const machineShare = new PortalClient({
|
|
23
|
+
appUrl,
|
|
24
|
+
sessionToken,
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
onState: (state) => {
|
|
27
|
+
if (state.status === "connected") signalConnected();
|
|
28
|
+
if (state.status === "error") console.error(`portal: connection error: ${state.error ?? "unknown"} — retrying`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
machineShare.enable();
|
|
32
|
+
if (await Promise.race([connected.then(() => false), new Promise((resolve) => setTimeout(() => resolve(true), 3e4).unref())])) {
|
|
33
|
+
machineShare.dispose();
|
|
34
|
+
printError(`Could not connect the portal within 30s — machine sharing is unavailable (network, or the portal kill switch is off). ${timeoutHint ?? "Check `skydive portal status`."}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
return machineShare;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//#endregion
|
|
41
|
+
export { connectMachineShare };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as SandboxStream } from "./client-CpEvH2Pq.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/chat/sandbox/raw-pty.ts
|
|
5
|
+
/**
|
|
6
|
+
* The transport-and-TTY core of a live sandbox terminal: direct byte
|
|
7
|
+
* passthrough between a raw local TTY and the remote pty.
|
|
8
|
+
*
|
|
9
|
+
* local stdin → INPUT frames → sandbox pty
|
|
10
|
+
* sandbox pty → DATA frames → local stdout
|
|
11
|
+
*
|
|
12
|
+
* This is the correct shape for a real terminal: we don't reimplement a
|
|
13
|
+
* terminal emulator, we hand the actual TTY to the remote shell. Callers own
|
|
14
|
+
* the surrounding lifecycle — the chat TUI suspends/resumes its renderer
|
|
15
|
+
* around this (pty-session.ts), the standalone `skydive sandbox` command runs
|
|
16
|
+
* it bare.
|
|
17
|
+
*
|
|
18
|
+
* Detach key: Ctrl-] (0x1d), the classic telnet/ssh escape. Leaving the shell
|
|
19
|
+
* running server-side is NOT a goal here; detaching closes the session.
|
|
20
|
+
*/
|
|
21
|
+
const DETACH_BYTE = 29;
|
|
22
|
+
async function runRawPtyPassthrough(opts) {
|
|
23
|
+
const { stdin, stdout } = opts;
|
|
24
|
+
const size = () => ({
|
|
25
|
+
cols: stdout.columns ?? 80,
|
|
26
|
+
rows: stdout.rows ?? 24
|
|
27
|
+
});
|
|
28
|
+
return await new Promise((resolve) => {
|
|
29
|
+
let settled = false;
|
|
30
|
+
const initial = size();
|
|
31
|
+
const stream = SandboxStream.open({
|
|
32
|
+
mode: "pty",
|
|
33
|
+
appUrl: opts.appUrl,
|
|
34
|
+
sessionToken: opts.sessionToken,
|
|
35
|
+
agentId: opts.agentId,
|
|
36
|
+
cols: initial.cols,
|
|
37
|
+
rows: initial.rows,
|
|
38
|
+
onEvent: (e) => {
|
|
39
|
+
switch (e.type) {
|
|
40
|
+
case "data":
|
|
41
|
+
stdout.write(e.bytes);
|
|
42
|
+
break;
|
|
43
|
+
case "error":
|
|
44
|
+
stdout.write(`\r\n\x1b[31m${e.message}\x1b[0m\r\n`);
|
|
45
|
+
finish({
|
|
46
|
+
reason: "error",
|
|
47
|
+
code: 1
|
|
48
|
+
});
|
|
49
|
+
break;
|
|
50
|
+
case "exit":
|
|
51
|
+
finish({
|
|
52
|
+
reason: "exit",
|
|
53
|
+
code: e.code
|
|
54
|
+
});
|
|
55
|
+
break;
|
|
56
|
+
case "close":
|
|
57
|
+
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`);
|
|
58
|
+
finish({
|
|
59
|
+
reason: "error",
|
|
60
|
+
code: 1
|
|
61
|
+
});
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const onStdin = (chunk) => {
|
|
67
|
+
if (chunk.length === 1 && chunk[0] === DETACH_BYTE) {
|
|
68
|
+
finish({
|
|
69
|
+
reason: "detach",
|
|
70
|
+
code: 0
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
stream.sendInput(new Uint8Array(chunk));
|
|
75
|
+
};
|
|
76
|
+
const onResize = () => {
|
|
77
|
+
const s = size();
|
|
78
|
+
stream.resize(s.cols, s.rows);
|
|
79
|
+
};
|
|
80
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
81
|
+
stdin.setRawMode?.(true);
|
|
82
|
+
stdin.resume();
|
|
83
|
+
stdin.on("data", onStdin);
|
|
84
|
+
stdout.on("resize", onResize);
|
|
85
|
+
stream.resize(initial.cols, initial.rows);
|
|
86
|
+
function finish(result) {
|
|
87
|
+
if (settled) return;
|
|
88
|
+
settled = true;
|
|
89
|
+
stdin.off("data", onStdin);
|
|
90
|
+
stdout.off("resize", onResize);
|
|
91
|
+
stdin.setRawMode?.(wasRaw);
|
|
92
|
+
stream.close();
|
|
93
|
+
resolve(result);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
//#endregion
|
|
99
|
+
export { runRawPtyPassthrough as t };
|