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 ADDED
@@ -0,0 +1,68 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Skydive CLI are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-07-29
9
+
10
+ ### Added
11
+
12
+ **Sandbox & machine access**
13
+
14
+ - Standalone `skydive sandbox` command for direct access to an agent's sandbox.
15
+ - `/sandbox` in the chat TUI — live PTY session and one-shot command execution.
16
+ - Headless machine sharing via `skydive portal`, with self-registering portal grants.
17
+ - `shareMachineDefault` config key for always-on portal sharing.
18
+
19
+ **Chat TUI**
20
+
21
+ - File pane (`ctrl+g`) with Changes and Files tabs: a shared file tree plus source viewer and a workspace browser, mouse-resizable and responsive to terminal size.
22
+ - Agent todo list rendered in the chat TUI, above the composer, mirroring the web chat's todo card.
23
+ - Fuzzy finder in the conversation and agent pickers.
24
+ - Slash-command autocomplete menu.
25
+ - Paste or drag-and-drop any file into the composer; paste clipboard images with Cmd+V.
26
+ - Run local shell commands with `!` in the composer.
27
+ - Conversation recaps, streamed live title updates, and per-agent attribution on assistant turns.
28
+ - Working timer rolls up into minutes and hours.
29
+ - Conversation picker paginates past 50 conversations and shows only your own conversations.
30
+ - Esc leaves a live run; typing `exit` quits the chat.
31
+ - Cursor Dark theme.
32
+ - Picker rows no longer overlap when a conversation title contains a newline, tab, or control character.
33
+
34
+ **Headless & scripting**
35
+
36
+ - Resume a conversation by id.
37
+ - `conversations list` and `conversations show` for transcript reads.
38
+ - `messages get`, with run recovery keyed on message id.
39
+ - Connect cards surface in headless `-p` mode so a driving agent never gets stuck.
40
+
41
+ **Authentication**
42
+
43
+ - `skydive auth login` via the browser now auto-mints an API key, so one login yields both a chat session and a usable management credential. Management commands announce the key's pinned workspace when it drives them, so a workspace mismatch is visible at use time.
44
+ - Workspace picker on the device authorization page.
45
+ - Account and workspace identity shown in `auth status`.
46
+
47
+ **Platform**
48
+
49
+ - Standalone binary builds compiling the CLI into a per-target executable.
50
+ - Interactive workspace switcher; management commands follow the active workspace.
51
+ - Terminal host integrations and agent notifications.
52
+
53
+ ### Fixed
54
+
55
+ - Transcript errors collapse to one line, click to expand (REST and portal errors keep their full body).
56
+ - Dragged/pasted image file paths attach the file instead of inserting path text, including macOS paths with literal parentheses.
57
+ - Bare URLs in chat markdown are hyperlinked so they survive text wrap.
58
+ - Composer draft is preserved across TUI overlays.
59
+ - Relative connect links resolve before opening the browser.
60
+ - Numbered markdown headings render colored in the TUI.
61
+ - Chat transcript pages by 75% of a screen; picker rows stay on one line.
62
+ - Run starts push to the TUI over the conversation stream.
63
+ - Security: remediated high-severity dependency findings and cleared tar/shell-quote CVEs.
64
+
65
+ ## [0.1.0] - 2026-07-21
66
+
67
+ Initial public release: `skydive chat` TUI, agent and conversation management,
68
+ device authorization, and headless `-p` mode.
package/README.md CHANGED
@@ -8,25 +8,57 @@ npx skydive-cli --help
8
8
 
9
9
  ## Authentication
10
10
 
11
- The CLI has two credentials, stored together in `~/.config/skydive/config.json`:
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:
12
14
 
13
- - **API key** (`sky_live_…`) for the management commands. Mint one at
14
- `skydive.com/account`, then:
15
+ ```sh
16
+ skydive auth login
17
+ ```
15
18
 
16
- ```sh
17
- skydive auth login # paste the key
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` — and whenever the key
29
+ (not the session) is the credential actually driving a management command,
30
+ the CLI says so on stderr, naming the pinned workspace, so commands never
31
+ silently act in a workspace you switched away from.
32
+ - a **user session** for `skydive chat`. Chat is user-level and multi-agent,
33
+ so it authenticates as you — and unlike the API key (which is pinned to the
34
+ workspace that minted it), the session follows `skydive workspace switch`.
35
+ The management commands prefer the session while it's valid and fall back
36
+ to the API key.
37
+
38
+ The flow doesn't require a TTY: in a non-interactive shell (an agent harness,
39
+ SSH without a display) `auth login` still prints the verification URL + code
40
+ and waits — open the link in any browser to approve. For unattended CI, skip
41
+ the wait entirely and pass an existing key (mint one at
42
+ `skydive.com/settings/account`):
43
+
44
+ ```sh
45
+ skydive auth login --api-key sky_live_… # or set SKYDIVE_API_KEY
46
+ ```
19
47
 
20
- - **User session** for `skydive chat`. Chat is user-level and multi-agent,
21
- so it signs you in via the browser (device flow) rather than a per-agent
22
- API key. `skydive chat` does this automatically on first run; you can also
23
- do it up front:
48
+ `skydive auth status` shows both credentials; `skydive auth logout` revokes
49
+ the auto-minted key and clears them.
24
50
 
25
- ```sh
26
- skydive auth login --web # opens the browser, prompts for approval
27
- ```
51
+ The config location is platform-conventional (resolved via `env-paths`), so
52
+ it is **not** `~/.config/skydive/` everywhere:
28
53
 
29
- `skydive auth status` shows both; `skydive auth logout` clears them.
54
+ | Platform | Path |
55
+ | -------- | ---------------------------------------------------------------- |
56
+ | macOS | `~/Library/Preferences/skydive/config.json` |
57
+ | Linux | `~/.config/skydive/config.json` (or `$XDG_CONFIG_HOME/skydive/`) |
58
+ | Windows | `%APPDATA%\skydive\config.json` |
59
+
60
+ `skydive auth status` prints the real resolved path (`Config: …`) — trust that
61
+ over this table if they ever disagree.
30
62
 
31
63
  ## Commands
32
64
 
@@ -45,10 +77,62 @@ skydive secrets rm <KEY> --agent-id <id>
45
77
 
46
78
  skydive chat # interactive chat TUI (Node; fetches Bun on first run)
47
79
  skydive chat -p "<prompt>" # one-shot, non-interactive (runs under Node)
80
+
81
+ skydive import # agent imports your Claude Code / Cursor / OpenCode setup
82
+
83
+ skydive portal open # open the portal to this machine, headless
84
+ skydive portal grant --agent <id|name>
85
+ skydive portal revoke --agent <id|name>
86
+ skydive portal status
48
87
  ```
49
88
 
50
89
  Global flags: `--json`, `--quiet`, `--api-url <url>`.
51
90
 
91
+ ## `skydive import`
92
+
93
+ Migrating from Claude Code, Cursor, or OpenCode? `skydive import` opens a
94
+ chat with your agent, shares this machine with it, and asks it to import
95
+ your setup — instruction files, skills, slash commands, memory, MCP
96
+ servers. The agent inventories what you have, shows you a plan (including
97
+ what it will NOT bring: credentials never come along; hooks and
98
+ settings have no equivalent here), and only commits to its repo after you
99
+ say yes. Run it from a project directory so project-level config is found
100
+ too.
101
+
102
+ ```sh
103
+ skydive import # picks your only agent, or opens the picker
104
+ skydive import --agent kit # import into a specific agent
105
+ ```
106
+
107
+ The heavy lifting is the agent's `import-config` skill, so you can also just
108
+ ask in any machine-shared chat: "import my Claude Code setup".
109
+
110
+ Non-interactive (scripts, or driving it from another coding agent):
111
+ `skydive import -p --agent kit` sends the request as a one-shot and prints
112
+ the agent's plan — the agent still waits for approval, so continue with
113
+ `skydive chat -p "go ahead" --agent <id> --resume <conversation> --share-machine`
114
+ to apply it. Runs under Node, no TTY needed.
115
+
116
+ ## Workspaces
117
+
118
+ Every `skydive` command acts inside one **workspace** — the org/team boundary
119
+ that owns your agents, conversations, and secrets. If your account belongs to
120
+ several workspaces (say, your company org and a personal one), the same agent
121
+ name can exist in both, so which workspace is active determines what every
122
+ command sees.
123
+
124
+ - `skydive workspace list` — workspaces on your account, with the active one
125
+ marked.
126
+ - `skydive workspace switch [workspace]` — change the active workspace. All
127
+ commands — `agents`, `secrets`, `keys`, `chat`, `conversations` — follow it.
128
+ - `skydive auth status` — shows who you're logged in as and which workspace is
129
+ active.
130
+ - In the chat TUI, `ctrl+w` switches workspace from the agent picker.
131
+
132
+ The active workspace is stored in the CLI config file and persists across
133
+ invocations. If a command can't find an agent you know exists, check
134
+ `skydive auth status` first — you're probably in the wrong workspace.
135
+
52
136
  ## `skydive secrets`
53
137
 
54
138
  Manage the environment secrets injected into an agent's sandbox. All
@@ -58,7 +142,7 @@ API key with edit access to that agent.
58
142
  ```sh
59
143
  skydive secrets list --agent-id <id> # names only — values are never returned
60
144
  skydive secrets set OPENAI_API_KEY --agent-id <id> # reads the value from stdin
61
- echo -n "$TOKEN" | skydive secrets set MY_TOKEN --agent-id <id>
145
+ printf '%s' "$TOKEN" | skydive secrets set MY_TOKEN --agent-id <id>
62
146
  skydive secrets set MY_TOKEN "literal-value" --agent-id <id>
63
147
  skydive secrets rm MY_TOKEN --agent-id <id>
64
148
  ```
@@ -67,6 +151,11 @@ skydive secrets rm MY_TOKEN --agent-id <id>
67
151
  - `set` reads the value from **stdin** when no value argument is given, so the
68
152
  secret stays out of shell history. Passing it inline is supported for
69
153
  scripting but avoid it in an interactive shell.
154
+ - Use `printf '%s'` rather than `echo -n` when piping a value: `echo -n` is not
155
+ portable (some `sh` builtins emit `-n` literally instead of suppressing the
156
+ newline), which would silently store a corrupted value. Since values are
157
+ write-only and cannot be read back to verify, that failure stays invisible
158
+ until something downstream breaks.
70
159
  - **Values are write-only over the API.** `list` returns key names, never
71
160
  values — the same contract as the in-sandbox `platform secrets` surface.
72
161
  - Setting a secret updates the agent's vault. If an outbound-proxy rule
@@ -118,10 +207,22 @@ skydive chat -p "status?" --agent grace --json # structured envelo
118
207
  - `--conversation <id>` continues an existing thread; omitted, it starts a new
119
208
  one. The conversation id is included in `--json` output for chaining.
120
209
  - `-p` needs a user session just like `chat`, but never opens the interactive
121
- browser login — sign in first with `skydive auth login --web` or set
210
+ browser login — sign in first with `skydive auth login` or set
122
211
  `SKYDIVE_SESSION_TOKEN`.
123
212
  - `--json` prints `{ agentId, agentName, conversationId, isNewConversation,
124
- runId, text }` instead of streaming the raw text.
213
+ messageId, text }` instead of streaming the raw text.
214
+ - **Recovering a dropped run.** A long run's stream can be cut off at the edge
215
+ (a gateway 502/504) after the message was accepted. The agent keeps working
216
+ server-side, so a blind retry would re-execute it. Instead, note the
217
+ `messageId` (always in `--json` output) and fetch the result once it's done:
218
+
219
+ ```sh
220
+ skydive messages get <messageId> # prints the reply; --json for the envelope
221
+ ```
222
+
223
+ `messages get` re-attaches to the exchange behind that message id and replays
224
+ the full reply whether it's still streaming or already finished. Message ids
225
+ are the handle this API works in; run ids stay server-side.
125
226
 
126
227
  It defaults to the production API (`https://api.skydive.com`). For local
127
228
  dev, point it at your stack:
@@ -136,22 +237,123 @@ management API used by `agents`/`keys`.
136
237
 
137
238
  ### Keybindings
138
239
 
139
- | Context | Key | Action |
140
- | ------------ | ------------- | ------------------------------------ |
141
- | Pickers | _type_ | filter the list |
142
- | Pickers | `↑` / `↓` | move selection |
143
- | Pickers | `↵` | open / select |
144
- | Pickers | `esc` | back |
145
- | Agent picker | `ctrl+n` | create an agent |
146
- | Chat | `↵` | send (or queue, while streaming) |
147
- | Chat | `pgup`/`pgdn` | scroll the transcript |
148
- | Chat | `ctrl+c` | cancel the active run; again to quit |
149
- | Chat | `esc` | back to the conversation picker |
240
+ | Context | Key | Action |
241
+ | ------------------- | ------------- | ------------------------------------- |
242
+ | Pickers | _type_ | filter the list |
243
+ | Pickers | `↑` / `↓` | move selection |
244
+ | Pickers | `↵` | open / select |
245
+ | Pickers | `esc` | back |
246
+ | Agent picker | `ctrl+n` | create an agent |
247
+ | Agent picker | `ctrl+w` | switch workspace |
248
+ | Agent picker | `tab` | toggle mine / whole-org agents |
249
+ | Conversation picker | `ctrl+d` | delete the highlighted conversation |
250
+ | Chat | `↵` | send (or queue, while streaming) |
251
+ | Chat | `pgup`/`pgdn` | scroll the transcript |
252
+ | Chat | `ctrl+c` | cancel the active run; again to quit |
253
+ | Chat | `esc` | back to the conversation picker |
254
+ | Chat | `ctrl+p` | switch model |
255
+ | Chat | `ctrl+o` | switch theme |
256
+ | Chat | `ctrl+t` | grant / revoke local machine access |
257
+ | Chat | `ctrl+l` | open this conversation in the browser |
258
+ | Chat | `ctrl+r` | run the newest pending connect card |
259
+ | Chat | `ctrl+v` | paste an image from the clipboard |
260
+ | Chat | `ctrl+x` | drop the most recently staged image |
261
+ | Chat | `?` | show the keybinding reference |
150
262
 
151
263
  Messages typed while a response is streaming are **queued** and sent in
152
264
  order as each run finishes; `ctrl+c` cancels the current run and clears the
153
265
  queue.
154
266
 
267
+ ### Local machine access (`--share-machine` / `ctrl+t`)
268
+
269
+ The chat TUI can grant the agent the ability to run commands **on your local
270
+ machine** so it can read the repo you launched from, run local tooling, etc.
271
+ This is off by default (default-deny) and you opt in explicitly:
272
+
273
+ - `skydive chat --share-machine` enables it at launch, or
274
+ - `ctrl+t` toggles it from the chat screen (the status bar shows `local access`
275
+ when on, `revoke access` when off), or
276
+ - set `"shareMachineDefault": true` in the CLI config file to share on every
277
+ launch (see [Configuration](#configuration); `--no-share-machine` overrides
278
+ once).
279
+
280
+ What to know before enabling it:
281
+
282
+ - **Per-agent.** The grant is scoped to the agent you're chatting with; other
283
+ agents don't inherit it.
284
+ - **The working directory is a starting point, not a jail.** Commands run with
285
+ their working directory set to wherever you launched `skydive chat`, but this
286
+ is **not** a hard filesystem boundary. This is genuine remote command
287
+ execution (a WebSocket streaming stdin/stdout/stderr), so the agent runs
288
+ commands as your user and can reach anything your user can, including paths
289
+ outside the launch directory. Launch from a scoped directory, but treat the
290
+ grant as "this agent can act as me on this machine," not "this agent can only
291
+ see this folder."
292
+ - **Teardown drops reachability, not the grant.** Quitting the chat closes the
293
+ portal connection (the machine becomes unreachable) and reaps any processes
294
+ the agent spawned, but the per-agent grant itself **persists** until you
295
+ revoke it (`skydive portal revoke` or the web app). The next time this
296
+ machine shares, previously granted agents can run commands immediately, with
297
+ no new prompt.
298
+ - **The TUI is not required.** With `-p`, `--share-machine` shares this machine
299
+ for the duration of that one run: the portal connects before the prompt is
300
+ sent, the target agent is granted access (the flag is the consent — there is
301
+ no prompt to approve), and the connection is torn down when the run ends.
302
+ For access that outlives a single run, use `skydive portal open` (below).
303
+ When sharing comes from `shareMachineDefault` rather than the explicit
304
+ flag, no new grant is minted — the machine connects and already-granted
305
+ agents can run, but an ungranted agent still needs `skydive portal grant`
306
+ (or an approved request).
307
+
308
+ ### Headless machine access (`skydive portal`)
309
+
310
+ `skydive portal` manages machine sharing without the TUI, for scripts, CI-ish
311
+ environments, and driving agents from other tools:
312
+
313
+ ```sh
314
+ skydive portal open # connect and stay in the foreground (ctrl+c to close)
315
+ skydive portal open --agent grace # …and grant that agent on first connect
316
+ skydive portal open --cwd ~/some/repo # working directory for the agent's commands (a starting point, not a boundary — see above)
317
+ skydive portal grant --agent grace # allow an agent (registers this machine if needed)
318
+ skydive portal revoke --agent grace # remove an agent's access
319
+ skydive portal status # machines, connection state, granted agents
320
+ ```
321
+
322
+ Semantics match the TUI exactly \u2014 same default-deny grants, same audit log,
323
+ same server-side kill switch. `open` keeps the machine reachable only while
324
+ the process runs (park it in tmux for long-lived access); `grant`/`revoke`
325
+ edit the persistent per-agent access.
326
+
327
+ Two unattended patterns:
328
+
329
+ - **One-shot:** `skydive chat -p "<prompt>" --agent <name> --share-machine` \u2014
330
+ shares, grants the target agent, runs the prompt, tears down.
331
+ - **Daemon:** one `skydive portal open` process, plus plain
332
+ `skydive chat -p "<prompt>" --agent <name>` one-shots from another shell \u2014
333
+ cheaper when firing many prompts, since each one skips the connect/grant.
334
+
335
+ ## Configuration
336
+
337
+ Persistent state lives in a per-user JSON file managed by
338
+ [`conf`](https://github.com/sindresorhus/conf):
339
+ `~/Library/Preferences/skydive/config.json` on macOS,
340
+ `~/.config/skydive/config.json` on Linux (profile name follows
341
+ `SKYDIVE_CONFIG_NAME`). Most keys are written by flows — `auth login`, the
342
+ device-login flow, the in-TUI theme picker — but the file is plain JSON and
343
+ read fresh on every invocation, so preference keys can be set by editing it
344
+ directly. There is deliberately no `skydive config` command.
345
+
346
+ Hand-editable keys:
347
+
348
+ | Key | Type | Effect |
349
+ | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
350
+ | `shareMachineDefault` | boolean | When `true`, `skydive chat` (TUI and `-p`) shares this machine over the portal on launch, as if `--share-machine` were passed. An explicit `--share-machine`/`--no-share-machine` overrides it per invocation. Default `false`. |
351
+ | `updateCheck` | boolean | When `false`, disables the daily background update check and its "Update available" notice. The persistent equivalent of setting `SKYDIVE_NO_UPDATE_CHECK` (or `NO_UPDATE_NOTIFIER`) in the environment. Default `true`. |
352
+
353
+ The remaining keys (`apiKey`, `apiUrl`, `sessionToken`, `appUrl`, `themeDark`,
354
+ `themeLight`, …) are credentials or flow-managed state — leave them to the
355
+ CLI.
356
+
155
357
  ## Environment variables
156
358
 
157
359
  | Variable | Effect |
@@ -160,3 +362,44 @@ queue.
160
362
  | `SKYDIVE_API_URL` | Management API base URL |
161
363
  | `SKYDIVE_SESSION_TOKEN` | Chat session token (for non-interactive use) |
162
364
  | `SKYDIVE_APP_URL` | Chat app/session origin (overrides stored value) |
365
+ | `SKYDIVE_THEME` | Chat colorscheme id (flag `--theme` overrides) |
366
+ | `SKYDIVE_CONFIG_NAME` | On-disk config profile name (default `skydive`) |
367
+
368
+ ## Testing the TUI
369
+
370
+ `yarn test:unit` runs both suites: unit tests (`*.test.ts`) under vitest,
371
+ then TUI tests (`*.tui.test.tsx`) under `bun test` (needs [Bun](https://bun.sh)
372
+ ≥ 1.3.14 on your PATH — see `engines`).
373
+
374
+ TUI tests render the real OpenTUI screens headlessly — fixed terminal size,
375
+ mock keyboard input, frames captured as plain text — via
376
+ `@opentui/react/test-utils`. OpenTUI needs Bun's FFI, which is why they run
377
+ under `bun test`, not vitest:
378
+
379
+ ```sh
380
+ bun test .tui.test # just the TUI suite
381
+ ```
382
+
383
+ Frame snapshots live in `__snapshots__/` next to each test; a diff there is
384
+ a visual diff of the screen. `yarn render:frames <dir>` writes true-color
385
+ ANSI captures of key screens — CI converts them to PNGs (textimg) and posts
386
+ them as a sticky screenshot comment on CLI PRs.
387
+
388
+ The pattern (stub `fetch`, seed the store, drive keys, assert on
389
+ `captureCharFrame()`) is in `src/chat/tui/screens/agent-picker.tui.test.tsx`.
390
+
391
+ ## Building a standalone binary
392
+
393
+ `yarn build:binary` compiles the CLI into a single self-contained executable
394
+ (`dist/binaries/skydive-<target>`) with `bun build --compile` — no Node or
395
+ Bun needed on the machine that runs it, including for the OpenTUI chat
396
+ (`ensureBunAndReexec` no-ops because the binary _is_ Bun). Needs Bun ≥ 1.3.14
397
+ to build; pass `--target` for one of `bun-darwin-arm64`, `bun-darwin-x64`,
398
+ `bun-linux-x64`, `bun-linux-arm64` (defaults to the host, and the target's
399
+ `@opentui/core-*` optional dependency must be installed).
400
+
401
+ The script encodes three non-obvious constraints (older Bun emits unsignable
402
+ Mach-Os, OpenTUI's tree-sitter worker needs explicit embedding, and
403
+ `NODE_ENV=production` silently breaks highlight repaints) — see the header
404
+ comment in `scripts/build-binary.ts` before changing any of the flags it
405
+ passes.
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ import { t as HttpError } from "./rest-CamHVOce.mjs";
3
+ import os from "node:os";
4
+ import { z } from "zod";
5
+
6
+ //#region src/chat/portal/machine.ts
7
+ /**
8
+ * Identity this machine registers under when the CLI shares it via the portal.
9
+ *
10
+ * The `-cli` suffix / `(CLI)` label keep a CLI-shared machine a DISTINCT portal
11
+ * device from the same host's Skydive Desktop app. `portal_device` is unique on
12
+ * (org, user, machineName), and directives route to whichever socket holds the
13
+ * device — if the CLI and desktop registered the same name they'd share a
14
+ * device row and both execute every directive. Distinct names also make the
15
+ * grant UI unambiguous about which surface is being authorized.
16
+ */
17
+ function machineIdentity() {
18
+ const host = (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
19
+ return {
20
+ machineName: `${host}-cli`,
21
+ friendlyName: `${host} (CLI)`
22
+ };
23
+ }
24
+ const INHERITED_ENV = [
25
+ "HOME",
26
+ "USER",
27
+ "LOGNAME",
28
+ "SHELL",
29
+ "LANG",
30
+ "LC_ALL",
31
+ "TMPDIR",
32
+ "TERM",
33
+ "PATH"
34
+ ];
35
+ function buildEnv(extra) {
36
+ const env = {};
37
+ for (const key of INHERITED_ENV) {
38
+ const value = process.env[key];
39
+ if (value !== void 0) env[key] = value;
40
+ }
41
+ if (extra) for (const [key, value] of Object.entries(extra)) env[key] = value;
42
+ return env;
43
+ }
44
+ /**
45
+ * Build the desktop-portal WebSocket URL from the chat origin. Mirrors the Rust
46
+ * desktop client: http→ws, https→wss, scheme-less defaults to wss, and the
47
+ * machine/label ride as query pairs (percent-encoded by URL).
48
+ */
49
+ function portalWsUrl(appUrl, machine, label) {
50
+ const base = appUrl.replace(/\/+$/, "");
51
+ let wsBase;
52
+ if (base.startsWith("https://")) wsBase = `wss://${base.slice(8)}`;
53
+ else if (base.startsWith("http://")) wsBase = `ws://${base.slice(7)}`;
54
+ else wsBase = `wss://${base}`;
55
+ const url = new URL(`${wsBase}/api/v1/portal/desktop`);
56
+ url.searchParams.set("machine", machine);
57
+ url.searchParams.set("label", label);
58
+ return url.toString();
59
+ }
60
+
61
+ //#endregion
62
+ //#region src/chat/portal/api.ts
63
+ /**
64
+ * The portal's session-authed REST surface, shared by `PortalClient` (the
65
+ * TUI/`portal open` connection) and the `skydive portal` management
66
+ * commands, so the endpoint contracts and response schemas live in exactly
67
+ * one place.
68
+ */
69
+ const deviceSchema = z.object({
70
+ id: z.string(),
71
+ machineName: z.string(),
72
+ friendlyName: z.string(),
73
+ connected: z.boolean(),
74
+ lastSeen: z.string().nullable(),
75
+ grantedAgentIds: z.array(z.string())
76
+ });
77
+ const devicesResponseSchema = z.object({
78
+ devices: z.array(deviceSchema),
79
+ agents: z.array(z.object({
80
+ id: z.string(),
81
+ name: z.string()
82
+ }))
83
+ });
84
+ const deviceTokenSchema = z.object({ token: z.string().min(1) });
85
+ async function portalFetch(auth, path, init) {
86
+ const res = await fetch(`${auth.appUrl}${path}`, {
87
+ method: init.method,
88
+ headers: {
89
+ authorization: `Bearer ${auth.sessionToken}`,
90
+ accept: "application/json",
91
+ ...init.body ? { "content-type": "application/json" } : {}
92
+ },
93
+ ...init.body ? { body: init.body } : {}
94
+ });
95
+ if (!res.ok) {
96
+ const body = await res.text().catch(() => "");
97
+ throw new HttpError(res.status, body);
98
+ }
99
+ return res.json();
100
+ }
101
+ async function fetchPortalDevices(auth) {
102
+ const json = await portalFetch(auth, "/api/v1/portal/devices", { method: "GET" });
103
+ return devicesResponseSchema.parse(json);
104
+ }
105
+ const registerResponseSchema = z.object({ device: z.object({ id: z.string() }) });
106
+ /**
107
+ * Register this machine's device row without connecting. Connecting registers
108
+ * as a side effect; this covers granting an agent on a machine that has never
109
+ * shared yet (the grant references the device row).
110
+ */
111
+ async function registerPortalDevice(auth, { machineName, friendlyName }) {
112
+ const json = await portalFetch(auth, "/api/v1/portal/devices", {
113
+ method: "POST",
114
+ body: JSON.stringify({
115
+ machineName,
116
+ friendlyName
117
+ })
118
+ });
119
+ return registerResponseSchema.parse(json).device;
120
+ }
121
+ /** Short-lived token the machine presents when dialing the portal WebSocket. */
122
+ async function mintPortalDeviceToken(auth) {
123
+ const json = await portalFetch(auth, "/api/v1/portal/device-token", { method: "POST" });
124
+ return deviceTokenSchema.parse(json).token;
125
+ }
126
+ async function grantPortalAccess(auth, { deviceId, agentId }) {
127
+ await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
128
+ method: "POST",
129
+ body: JSON.stringify({ agentId })
130
+ });
131
+ }
132
+ async function revokePortalAccess(auth, { deviceId, agentId }) {
133
+ await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants/${encodeURIComponent(agentId)}`, { method: "DELETE" });
134
+ }
135
+ /**
136
+ * The device row for a given machine identity. Matching is by `machineName`
137
+ * equality — the stable per-surface handle (`<host>-cli` vs the desktop's
138
+ * `<host>`), not the display label.
139
+ */
140
+ function findThisDevice(devices, machineName) {
141
+ return devices.find((device) => device.machineName === machineName) ?? null;
142
+ }
143
+
144
+ //#endregion
145
+ export { registerPortalDevice as a, machineIdentity as c, mintPortalDeviceToken as i, portalWsUrl as l, findThisDevice as n, revokePortalAccess as o, grantPortalAccess as r, buildEnv as s, fetchPortalDevices as t };