skydive-cli 0.1.0-beta.186 → 0.1.0-beta.189

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/dist/js/bin.mjs CHANGED
@@ -31,7 +31,7 @@ var __exportAll = (all, no_symbols) => {
31
31
 
32
32
  //#endregion
33
33
  //#region package.json
34
- var version$1 = "0.1.0-beta.186";
34
+ var version$1 = "0.1.0-beta.189";
35
35
 
36
36
  //#endregion
37
37
  //#region src/types.ts
@@ -2152,7 +2152,7 @@ const chatCommand = {
2152
2152
  printError(`Unknown theme "${themeId}". Valid themes: ${themes.map((t) => t.id).join(", ")}`);
2153
2153
  process.exit(1);
2154
2154
  }
2155
- const { runChat } = await import("./boot-BEMmVR8C.mjs");
2155
+ const { runChat } = await import("./boot-DaU-F_bM.mjs");
2156
2156
  await runChat({
2157
2157
  appUrl,
2158
2158
  sessionToken: session.value.sessionToken,
@@ -3330,7 +3330,7 @@ const switchCommand = {
3330
3330
  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.");
3331
3331
  process.exit(1);
3332
3332
  }
3333
- const { runWorkspacePicker } = await import("./boot-BEMmVR8C.mjs");
3333
+ const { runWorkspacePicker } = await import("./boot-DaU-F_bM.mjs");
3334
3334
  await runWorkspacePicker(session);
3335
3335
  return;
3336
3336
  }
@@ -3457,4 +3457,4 @@ function resolveArgv(args, tty = {
3457
3457
  createCli(resolveArgv(hideBin(process.argv))).parse();
3458
3458
 
3459
3459
  //#endregion
3460
- export { resolveWebUrl as A, themesForMode as C, DEFAULT_API_URL as D, setActiveWorkspace as E, DEFAULT_APP_URL as O, themeVersion as S, listWorkspaces as T, noColorRequested as _, parseOauthConnectParams as a, themeMode as b, parseConnectCard as c, HttpError as d, createRestClient as f, monoTheme as g, findTheme as h, parseExternalOauthConnectParams as i, saveTheme as j, getSavedTheme as k, errorMessage as l, applyTheme as m, MASK_CHAR as n, reconcileMaskedInput as o, DEFAULT_THEME_ID as p, cardActionErrorMessage as r, resolveConnectUrl as s, resolveAgent as t, isRecord as u, theme as v, getActiveWorkspaceId as w, themeModeFromColorFgBg as x, themeForMode as y };
3460
+ export { getSavedTheme as A, themesForMode as C, DEFAULT_API_URL as D, setActiveWorkspace as E, saveTheme as M, DEFAULT_APP_URL as O, themeVersion as S, listWorkspaces as T, noColorRequested as _, parseOauthConnectParams as a, themeMode as b, parseConnectCard as c, HttpError as d, createRestClient as f, monoTheme as g, findTheme as h, parseExternalOauthConnectParams as i, resolveWebUrl as j, getConfigPath as k, errorMessage as l, applyTheme as m, MASK_CHAR as n, reconcileMaskedInput as o, DEFAULT_THEME_ID as p, cardActionErrorMessage as r, resolveConnectUrl as s, resolveAgent as t, isRecord as u, theme as v, getActiveWorkspaceId as w, themeModeFromColorFgBg as x, themeForMode as y };
@@ -1,17 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { A as resolveWebUrl, C as themesForMode, D as DEFAULT_API_URL, E as setActiveWorkspace, O as DEFAULT_APP_URL, S as themeVersion, T as listWorkspaces, _ as noColorRequested, a as parseOauthConnectParams, b as themeMode, c as parseConnectCard, d as HttpError, f as createRestClient, g as monoTheme, h as findTheme, i as parseExternalOauthConnectParams, j as saveTheme, k as getSavedTheme, l as errorMessage, m as applyTheme, n as MASK_CHAR, o as reconcileMaskedInput, p as DEFAULT_THEME_ID, r as cardActionErrorMessage, s as resolveConnectUrl, t as resolveAgent, u as isRecord, v as theme, w as getActiveWorkspaceId, x as themeModeFromColorFgBg, y as themeForMode } from "./bin.mjs";
2
+ import { A as getSavedTheme, C as themesForMode, D as DEFAULT_API_URL, E as setActiveWorkspace, M as saveTheme, O as DEFAULT_APP_URL, S as themeVersion, T as listWorkspaces, _ as noColorRequested, a as parseOauthConnectParams, b as themeMode, c as parseConnectCard, d as HttpError, f as createRestClient, g as monoTheme, h as findTheme, i as parseExternalOauthConnectParams, j as resolveWebUrl, k as getConfigPath, l as errorMessage, m as applyTheme, n as MASK_CHAR, o as reconcileMaskedInput, p as DEFAULT_THEME_ID, r as cardActionErrorMessage, s as resolveConnectUrl, t as resolveAgent, u as isRecord, v as theme, w as getActiveWorkspaceId, x as themeModeFromColorFgBg, y as themeForMode } from "./bin.mjs";
3
3
  import path, { basename, isAbsolute, join, win32 } from "node:path";
4
4
  import { z } from "zod";
5
5
  import open from "open";
6
- import { spawn } from "node:child_process";
6
+ import { execFile, spawn } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { constants } from "node:fs";
7
9
  import { MarkdownRenderable, RenderableEvents, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks } from "@opentui/core";
8
10
  import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
9
11
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
10
12
  import { create } from "zustand";
11
13
  import { WebSocket } from "ws";
14
+ import * as os$1 from "node:os";
12
15
  import os, { homedir, platform, release, tmpdir } from "node:os";
13
16
  import { createConnection } from "node:net";
14
- import { appendFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
17
+ import { access, appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
15
18
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
16
19
  import { fileURLToPath } from "node:url";
17
20
  import { fileTypeFromBuffer } from "file-type";
@@ -3073,6 +3076,44 @@ function parseMatches(output) {
3073
3076
  return null;
3074
3077
  }
3075
3078
 
3079
+ //#endregion
3080
+ //#region src/chat/tui/chunks/shell-local.tsx
3081
+ /**
3082
+ * Renders a `! <cmd>` local-shell escape: the command the user ran on their own
3083
+ * machine, followed by its combined stdout/stderr. Visually distinct from agent
3084
+ * tool calls (which run in the sandbox) — a `!` sigil colored by outcome (dim
3085
+ * while running, success/error once done), so a glance tells you this executed
3086
+ * locally, not on the agent.
3087
+ */
3088
+ function ShellLocalItem({ item }) {
3089
+ const running = item.exit === null;
3090
+ const failed = item.exit !== null && item.exit !== 0;
3091
+ return /* @__PURE__ */ jsxs("box", {
3092
+ style: { flexDirection: "column" },
3093
+ children: [/* @__PURE__ */ jsxs("text", { children: [
3094
+ /* @__PURE__ */ jsx("span", {
3095
+ fg: running ? theme.dim : failed ? theme.error : theme.success,
3096
+ children: "! "
3097
+ }),
3098
+ /* @__PURE__ */ jsx("span", {
3099
+ fg: theme.tool,
3100
+ children: item.command
3101
+ }),
3102
+ running ? /* @__PURE__ */ jsx("span", {
3103
+ fg: theme.dim,
3104
+ children: " · running…"
3105
+ }) : null,
3106
+ failed ? /* @__PURE__ */ jsxs("span", {
3107
+ fg: theme.error,
3108
+ children: [" · exit ", item.exit]
3109
+ }) : null
3110
+ ] }), item.output ? /* @__PURE__ */ jsx(Indented, { children: /* @__PURE__ */ jsx("text", {
3111
+ fg: theme.muted,
3112
+ children: item.output.replace(/\n+$/, "")
3113
+ }) }) : null]
3114
+ });
3115
+ }
3116
+
3076
3117
  //#endregion
3077
3118
  //#region src/chat/tui/chunks/index.tsx
3078
3119
  /**
@@ -3162,6 +3203,10 @@ function RenderItem({ item }) {
3162
3203
  ]
3163
3204
  })
3164
3205
  });
3206
+ case "shell": return /* @__PURE__ */ jsx(Row, {
3207
+ barColor: null,
3208
+ children: /* @__PURE__ */ jsx(ShellLocalItem, { item })
3209
+ });
3165
3210
  }
3166
3211
  }
3167
3212
  function StreamingAssistantText({ text }) {
@@ -4661,6 +4706,371 @@ function ThemePicker({ onClose }) {
4661
4706
  });
4662
4707
  }
4663
4708
 
4709
+ //#endregion
4710
+ //#region src/chat/shell/snapshot.ts
4711
+ /**
4712
+ * Shell snapshot for the `!` local-shell escape.
4713
+ *
4714
+ * The chat composer runs on the user's own machine. When they type `! <cmd>`,
4715
+ * we want that command to see their real interactive-shell setup — aliases,
4716
+ * functions, shell options, PATH — the same way it would in their terminal.
4717
+ *
4718
+ * Aliases and functions live in ~/.zshrc / ~/.bashrc, which non-interactive
4719
+ * shells don't source (or source without alias expansion), and are never
4720
+ * exported to child processes. Re-sourcing the rc file on every command is slow
4721
+ * (100s of ms with nvm/rbenv/oh-my-zsh) and can hang on interactive prompts.
4722
+ *
4723
+ * So, once per session, we run the user's login shell, source their rc, and
4724
+ * serialize the resulting state into a standalone .sh file. Every `!` command
4725
+ * then sources that cheap file and runs. This mirrors the approach Claude Code
4726
+ * uses for its bash mode.
4727
+ *
4728
+ * The snapshot is cached on disk keyed by (shell, rc-file mtime), so it is
4729
+ * built once and reused across CLI sessions, and regenerated automatically when
4730
+ * the user edits their rc file. There is deliberately no `!!` sigil — a second
4731
+ * bang has a base shell meaning (history expansion) we do not override; the
4732
+ * agent-sandbox counterpart is the `/sandbox` slash command, not a sigil.
4733
+ */
4734
+ const SNAPSHOT_CREATION_TIMEOUT_MS = 1e4;
4735
+ const SNAPSHOT_MAX_BUFFER = 1024 * 1024;
4736
+ /**
4737
+ * Resolve which shell the `!` escape uses. Order:
4738
+ * $SKYDIVE_SHELL (if it names bash/zsh and is executable)
4739
+ * $SHELL (if it names bash/zsh and is executable)
4740
+ * `zsh` / `bash` found on $PATH
4741
+ * `zsh` / `bash` in common install locations
4742
+ * /bin/sh as a last resort
4743
+ * Preference is bash-first when $SHELL mentions bash, else zsh-first — matching
4744
+ * the user's actual login shell where we can tell.
4745
+ */
4746
+ async function resolveShell(env = process.env) {
4747
+ const override = env.SKYDIVE_SHELL;
4748
+ if (override && /bash|zsh/.test(override) && await isExecutable(override)) return describeShell(override);
4749
+ const shellEnv = env.SHELL;
4750
+ if (shellEnv && /bash|zsh/.test(shellEnv) && await isExecutable(shellEnv)) return describeShell(shellEnv);
4751
+ const candidates = !!shellEnv && shellEnv.includes("bash") ? ["bash", "zsh"] : ["zsh", "bash"];
4752
+ for (const name of candidates) {
4753
+ const found = await whichOnPath(name, env);
4754
+ if (found) return describeShell(found);
4755
+ }
4756
+ const searchDirs = [
4757
+ "/bin",
4758
+ "/usr/bin",
4759
+ "/usr/local/bin",
4760
+ "/opt/homebrew/bin",
4761
+ "/run/current-system/sw/bin"
4762
+ ];
4763
+ for (const name of candidates) for (const dir of searchDirs) {
4764
+ const full = path.join(dir, name);
4765
+ if (await isExecutable(full)) return describeShell(full);
4766
+ }
4767
+ return describeShell("/bin/sh");
4768
+ }
4769
+ /**
4770
+ * Resolve `name` against $PATH, returning the first executable match, or
4771
+ * undefined. Pure filesystem lookup (no subprocess), matching `command -v`
4772
+ * semantics for a bare command name.
4773
+ */
4774
+ async function whichOnPath(name, env) {
4775
+ const pathVar = env.PATH ?? "";
4776
+ if (!pathVar) return void 0;
4777
+ for (const dir of pathVar.split(path.delimiter)) {
4778
+ if (!dir) continue;
4779
+ const full = path.join(dir, name);
4780
+ if (await isExecutable(full)) return full;
4781
+ }
4782
+ }
4783
+ function describeShell(bin) {
4784
+ const kind = bin.includes("zsh") ? "zsh" : bin.includes("bash") ? "bash" : "sh";
4785
+ const rcName = kind === "zsh" ? ".zshrc" : kind === "bash" ? ".bashrc" : ".profile";
4786
+ return {
4787
+ bin,
4788
+ kind,
4789
+ rcFile: path.join(os$1.homedir(), rcName)
4790
+ };
4791
+ }
4792
+ async function isExecutable(p) {
4793
+ try {
4794
+ await access(p, constants.X_OK);
4795
+ return true;
4796
+ } catch {
4797
+ return false;
4798
+ }
4799
+ }
4800
+ /** Directory where cached snapshots live (next to the CLI's Conf store). */
4801
+ function snapshotsDir() {
4802
+ return path.join(path.dirname(getConfigPath()), "shell-snapshots");
4803
+ }
4804
+ /**
4805
+ * Cache key: the snapshot is valid as long as the shell binary and the rc
4806
+ * file's mtime are unchanged. Editing the rc regenerates on the next `!`.
4807
+ */
4808
+ async function cacheKey(shell) {
4809
+ let rcMtime = "none";
4810
+ try {
4811
+ rcMtime = String((await stat(shell.rcFile)).mtimeMs);
4812
+ } catch {}
4813
+ return createHash("sha256").update(`${shell.bin}\0${shell.rcFile}\0${rcMtime}`).digest("hex").slice(0, 16);
4814
+ }
4815
+ function cachePath(shell, key) {
4816
+ return path.join(snapshotsDir(), `snapshot-${shell.kind}-${key}.sh`);
4817
+ }
4818
+ /**
4819
+ * Build the script we run inside the user's login shell to emit the snapshot.
4820
+ * Exported for tests — the generated script is the load-bearing surface.
4821
+ */
4822
+ function buildSnapshotScript(shell, snapshotFilePath, rcFileExists, pathValue) {
4823
+ const isZsh = shell.kind === "zsh";
4824
+ const header = [
4825
+ `SNAPSHOT_FILE=${shQuote(snapshotFilePath)}`,
4826
+ rcFileExists ? `source ${shQuote(shell.rcFile)} < /dev/null` : "# no rc file to source",
4827
+ `echo "# skydive shell snapshot" >| "$SNAPSHOT_FILE"`,
4828
+ `echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE"`
4829
+ ].join("\n");
4830
+ const functions = isZsh ? [
4831
+ `echo "# functions" >> "$SNAPSHOT_FILE"`,
4832
+ `typeset -f > /dev/null 2>&1`,
4833
+ `typeset +f | grep -vE '^_[^_]' | while read func; do`,
4834
+ ` typeset -f "$func" >> "$SNAPSHOT_FILE"`,
4835
+ `done`
4836
+ ].join("\n") : [
4837
+ `echo "# functions" >> "$SNAPSHOT_FILE"`,
4838
+ `declare -f > /dev/null 2>&1`,
4839
+ `declare -F | cut -d' ' -f3 | grep -vE '^_[^_]' | while read func; do`,
4840
+ ` encoded_func=$(declare -f "$func" | base64)`,
4841
+ ` echo "eval \\"\\$(echo '$encoded_func' | base64 -d)\\" > /dev/null 2>&1" >> "$SNAPSHOT_FILE"`,
4842
+ `done`
4843
+ ].join("\n");
4844
+ const options = isZsh ? [`echo "# options" >> "$SNAPSHOT_FILE"`, `setopt | sed 's/^/setopt /' | head -n 1000 >> "$SNAPSHOT_FILE"`].join("\n") : [
4845
+ `echo "# options" >> "$SNAPSHOT_FILE"`,
4846
+ `shopt -p | head -n 1000 >> "$SNAPSHOT_FILE"`,
4847
+ `set -o | grep "on" | awk '{print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE"`,
4848
+ `echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"`
4849
+ ].join("\n");
4850
+ const aliases = [`echo "# aliases" >> "$SNAPSHOT_FILE"`, `alias | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"`].join("\n");
4851
+ const pathPin = `echo "export PATH=${shQuote(pathValue)}" >> "$SNAPSHOT_FILE"`;
4852
+ const bashNoRcExpand = !rcFileExists && shell.kind === "bash" ? `echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"` : "# rc present or non-bash";
4853
+ return [
4854
+ header,
4855
+ rcFileExists ? functions : "# no rc: skip functions",
4856
+ rcFileExists ? options : bashNoRcExpand,
4857
+ rcFileExists ? aliases : "# no rc: skip aliases",
4858
+ pathPin,
4859
+ `if [ ! -f "$SNAPSHOT_FILE" ]; then echo "snapshot not created" >&2; exit 1; fi`
4860
+ ].join("\n\n");
4861
+ }
4862
+ /**
4863
+ * Ensure a valid snapshot exists and return its path, or undefined if creation
4864
+ * failed (soft failure — the caller falls back to a login shell). Reuses a
4865
+ * cached snapshot when the rc file is unchanged.
4866
+ */
4867
+ async function ensureSnapshot(shell, env = process.env) {
4868
+ const out = cachePath(shell, await cacheKey(shell));
4869
+ try {
4870
+ await access(out);
4871
+ return out;
4872
+ } catch {}
4873
+ let rcFileExists = false;
4874
+ try {
4875
+ await access(shell.rcFile);
4876
+ rcFileExists = true;
4877
+ } catch {
4878
+ rcFileExists = false;
4879
+ }
4880
+ await mkdir(snapshotsDir(), { recursive: true });
4881
+ const script = buildSnapshotScript(shell, out, rcFileExists, env.PATH ?? "");
4882
+ if (!await new Promise((resolve) => {
4883
+ execFile(shell.bin, [
4884
+ "-c",
4885
+ "-l",
4886
+ script
4887
+ ], {
4888
+ env: {
4889
+ ...env,
4890
+ SHELL: shell.bin,
4891
+ GIT_EDITOR: "true"
4892
+ },
4893
+ timeout: SNAPSHOT_CREATION_TIMEOUT_MS,
4894
+ maxBuffer: SNAPSHOT_MAX_BUFFER,
4895
+ encoding: "utf8"
4896
+ }, (error) => resolve(!error));
4897
+ })) return void 0;
4898
+ try {
4899
+ await readFile(out, "utf8");
4900
+ return out;
4901
+ } catch {
4902
+ return;
4903
+ }
4904
+ }
4905
+ /** Shell-quote a single string for safe interpolation (single-quote wrap). */
4906
+ function shQuote(s) {
4907
+ return `'${s.replace(/'/g, `'\\''`)}'`;
4908
+ }
4909
+
4910
+ //#endregion
4911
+ //#region src/chat/shell/run.ts
4912
+ /**
4913
+ * Runs a single `! <cmd>` local-shell command with the user's snapshotted
4914
+ * shell environment (aliases / functions / options / PATH), and routes the
4915
+ * chat composer's raw input to the right destination.
4916
+ *
4917
+ * The per-command string is, joined by ` && `:
4918
+ *
4919
+ * source '<snapshot>' 2>/dev/null || true
4920
+ * shopt -u extglob 2>/dev/null || true # zsh: setopt NO_EXTENDED_GLOB
4921
+ * eval '<user command, shell-quoted>'
4922
+ * pwd -P >| '<cwd file>'
4923
+ *
4924
+ * Two details carry the whole feature:
4925
+ *
4926
+ * 1. `eval` is REQUIRED. A shell parses an entire line before executing any of
4927
+ * it, so aliases sourced on that same line are not yet available when the
4928
+ * line is parsed. Routing the command through `eval` forces a second parse
4929
+ * pass, at which point the aliases exist. `source snapshot && ll` would NOT
4930
+ * expand `ll`; `source snapshot && eval 'll'` does. This is the trick.
4931
+ *
4932
+ * 2. `pwd -P >| <file>`, read back after the process exits, is how cwd persists
4933
+ * across `!` commands — each command is a fresh process, so `! cd foo` then
4934
+ * `! ls` only works if we carry the cwd forward ourselves.
4935
+ *
4936
+ * When no snapshot could be built we fall back to a login shell (`-c -l`) so the
4937
+ * user at least gets their rc-driven environment, just slower.
4938
+ */
4939
+ /** Create a session: resolve the shell and build/reuse its snapshot once. */
4940
+ async function createShellSession(env = process.env) {
4941
+ const shell = await resolveShell(env);
4942
+ return {
4943
+ shell,
4944
+ snapshotPath: await ensureSnapshot(shell, env),
4945
+ cwd: process.cwd()
4946
+ };
4947
+ }
4948
+ /**
4949
+ * Assemble the `-c` command string for one command. Exported for tests — the
4950
+ * `eval` placement is the load-bearing bit and must be asserted directly.
4951
+ */
4952
+ function buildCommandString(session, command, cwdFile) {
4953
+ const parts = [];
4954
+ if (session.snapshotPath) parts.push(`source ${shQuote(session.snapshotPath)} 2>/dev/null || true`);
4955
+ parts.push(session.shell.kind === "zsh" ? "setopt NO_EXTENDED_GLOB 2>/dev/null || true" : "shopt -u extglob 2>/dev/null || true");
4956
+ parts.push(`eval ${shQuote(command)}`);
4957
+ parts.push(`pwd -P >| ${shQuote(cwdFile)}`);
4958
+ return parts.join(" && ");
4959
+ }
4960
+ /**
4961
+ * Spawn args for one command. Exported for tests. With a snapshot we spawn
4962
+ * NON-login (fast — the snapshot already carries env); without one, fall back
4963
+ * to a login shell so rc init still happens.
4964
+ */
4965
+ function spawnArgs(session, commandString) {
4966
+ return session.snapshotPath ? ["-c", commandString] : [
4967
+ "-c",
4968
+ "-l",
4969
+ commandString
4970
+ ];
4971
+ }
4972
+ /**
4973
+ * Run one command, streaming output via `onEvent`. Resolves with the exit code
4974
+ * once the process ends. Updates `session.cwd` from the persisted pwd so the
4975
+ * next command starts where this one left off.
4976
+ */
4977
+ async function runShellCommand(session, command, onEvent, env = process.env) {
4978
+ const tmp = await mkdtemp(path.join(os$1.tmpdir(), "skydive-sh-"));
4979
+ const cwdFile = path.join(tmp, "cwd");
4980
+ const commandString = buildCommandString(session, command, cwdFile);
4981
+ return new Promise((resolve) => {
4982
+ const child = spawn(session.shell.bin, spawnArgs(session, commandString), {
4983
+ cwd: session.cwd,
4984
+ env: {
4985
+ ...env,
4986
+ SHELL: session.shell.bin,
4987
+ GIT_EDITOR: "true"
4988
+ }
4989
+ });
4990
+ child.stdout.setEncoding("utf8");
4991
+ child.stderr.setEncoding("utf8");
4992
+ child.stdout.on("data", (chunk) => onEvent({
4993
+ type: "stdout",
4994
+ chunk
4995
+ }));
4996
+ child.stderr.on("data", (chunk) => onEvent({
4997
+ type: "stderr",
4998
+ chunk
4999
+ }));
5000
+ const finish = async (code, signal) => {
5001
+ try {
5002
+ const next = (await readFile(cwdFile, "utf8")).trim();
5003
+ if (next) session.cwd = next;
5004
+ } catch {}
5005
+ await rm(tmp, {
5006
+ recursive: true,
5007
+ force: true
5008
+ }).catch(() => {});
5009
+ onEvent({
5010
+ type: "exit",
5011
+ code,
5012
+ signal
5013
+ });
5014
+ resolve({
5015
+ code,
5016
+ signal
5017
+ });
5018
+ };
5019
+ child.on("error", () => void finish(null, null));
5020
+ child.on("close", (code, signal) => void finish(code, signal));
5021
+ });
5022
+ }
5023
+ function routeInput(raw) {
5024
+ const line = raw.trimEnd();
5025
+ if (line.startsWith("!")) {
5026
+ const command = line.slice(1).trim();
5027
+ return command ? {
5028
+ kind: "local-shell",
5029
+ command
5030
+ } : { kind: "noop" };
5031
+ }
5032
+ const sandbox = matchSlash(line, ["sandbox", "sbx"]);
5033
+ if (sandbox !== null) return sandbox ? {
5034
+ kind: "sandbox-exec",
5035
+ command: sandbox
5036
+ } : { kind: "sandbox-pty" };
5037
+ return {
5038
+ kind: "message",
5039
+ content: raw
5040
+ };
5041
+ }
5042
+ /**
5043
+ * If `line` is `/<name>` or `/<name> <rest>` for one of `names`, return the
5044
+ * trimmed rest (possibly ''); otherwise null. Case-insensitive on the name.
5045
+ */
5046
+ function matchSlash(line, names) {
5047
+ if (!line.startsWith("/")) return null;
5048
+ const sp = line.indexOf(" ");
5049
+ const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
5050
+ if (!names.includes(name)) return null;
5051
+ return sp === -1 ? "" : line.slice(sp + 1).trim();
5052
+ }
5053
+ /** Max characters of `!` output we forward to the agent as context. */
5054
+ const SHELL_CONTEXT_OUTPUT_CAP = 16e3;
5055
+ /**
5056
+ * Format a finished `! <cmd>` run as a context message for the agent: just the
5057
+ * command and its output, as a fenced console block (Claude Code parity — `!`
5058
+ * output enters the model's context). Trailing whitespace is trimmed and very
5059
+ * long output is truncated from the FRONT (the tail — errors, results — is
5060
+ * usually the informative part; the head is often progress noise). A non-zero
5061
+ * exit is annotated on the prompt line so the agent notices the failure.
5062
+ */
5063
+ function formatShellContext(command, output, exitCode) {
5064
+ let body = output.replace(/\s+$/, "");
5065
+ if (body.length > SHELL_CONTEXT_OUTPUT_CAP) body = `[… truncated to last ${SHELL_CONTEXT_OUTPUT_CAP} chars …]\n${body.slice(body.length - SHELL_CONTEXT_OUTPUT_CAP)}`;
5066
+ return [
5067
+ "```console",
5068
+ exitCode === 0 ? `$ ${command}` : `$ ${command} # exit ${exitCode}`,
5069
+ body,
5070
+ "```"
5071
+ ].join("\n");
5072
+ }
5073
+
4664
5074
  //#endregion
4665
5075
  //#region src/chat/tui/screens/chat.tsx
4666
5076
  /**
@@ -4756,6 +5166,7 @@ function ChatScreen({ agent, conversation }) {
4756
5166
  inputRef.current = input;
4757
5167
  const credPromptRef = useRef(credPrompt);
4758
5168
  credPromptRef.current = credPrompt;
5169
+ const shellSessionRef = useRef(null);
4759
5170
  const hostState = grantPrompt || credPrompt ? "blocked" : run.kind === "idle" ? "idle" : "working";
4760
5171
  useEffect(() => {
4761
5172
  agentHost.transition({ state: hostState });
@@ -4907,12 +5318,13 @@ function ChatScreen({ agent, conversation }) {
4907
5318
  cancelled = true;
4908
5319
  };
4909
5320
  }, []);
4910
- const sendContent = useCallback(async (content, attachments) => {
5321
+ const sendContent = useCallback(async (content, attachments, opts) => {
4911
5322
  if (!rest) return;
4912
5323
  const trimmed = content.trim();
4913
5324
  if (!trimmed && attachments.length === 0) return;
5325
+ const echo = opts?.echo !== false;
4914
5326
  const optimisticId = crypto.randomUUID();
4915
- setItems((prev) => [...prev, {
5327
+ if (echo) setItems((prev) => [...prev, {
4916
5328
  kind: "user",
4917
5329
  id: optimisticId,
4918
5330
  text: userItemText(trimmed, attachments)
@@ -4946,8 +5358,10 @@ function ChatScreen({ agent, conversation }) {
4946
5358
  id: crypto.randomUUID(),
4947
5359
  text: errorMessage(err)
4948
5360
  }]);
4949
- setInput((existing) => existing ? existing : trimmed);
4950
- setPending((prev) => prev.length > 0 ? prev : attachments);
5361
+ if (echo) {
5362
+ setInput((existing) => existing ? existing : trimmed);
5363
+ setPending((prev) => prev.length > 0 ? prev : attachments);
5364
+ }
4951
5365
  if (!wasStreaming) setRun({ kind: "idle" });
4952
5366
  }
4953
5367
  }, [
@@ -5139,10 +5553,62 @@ function ChatScreen({ agent, conversation }) {
5139
5553
  }
5140
5554
  })();
5141
5555
  }, [rest, updateCard]);
5556
+ const runLocalShell = useCallback(async (command) => {
5557
+ const id = crypto.randomUUID();
5558
+ setItems((prev) => [...prev, {
5559
+ kind: "shell",
5560
+ id,
5561
+ command,
5562
+ output: "",
5563
+ exit: null
5564
+ }]);
5565
+ let captured = "";
5566
+ const appendOutput = (chunk) => {
5567
+ captured += chunk;
5568
+ setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
5569
+ ...m,
5570
+ output: m.output + chunk
5571
+ } : m));
5572
+ };
5573
+ let exitCode;
5574
+ try {
5575
+ if (!shellSessionRef.current) shellSessionRef.current = await createShellSession();
5576
+ const { code } = await runShellCommand(shellSessionRef.current, command, (e) => {
5577
+ if (e.type === "stdout" || e.type === "stderr") appendOutput(e.chunk);
5578
+ });
5579
+ exitCode = code ?? 1;
5580
+ setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
5581
+ ...m,
5582
+ exit: exitCode
5583
+ } : m));
5584
+ } catch (err) {
5585
+ appendOutput(`\n${errorMessage(err)}`);
5586
+ exitCode = 1;
5587
+ setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
5588
+ ...m,
5589
+ exit: 1
5590
+ } : m));
5591
+ }
5592
+ sendContent(formatShellContext(command, captured, exitCode), [], { echo: false });
5593
+ }, [sendContent]);
5142
5594
  const submit = useCallback(() => {
5143
5595
  const content = input.trim();
5144
5596
  const attachments = pending;
5145
5597
  if (!content && attachments.length === 0) return;
5598
+ const routed = routeInput(content);
5599
+ if (routed.kind !== "message") {
5600
+ history.append(content);
5601
+ setInput("");
5602
+ composerRef.current?.clear();
5603
+ setComposerRows(1);
5604
+ if (routed.kind === "local-shell") runLocalShell(routed.command);
5605
+ else if (routed.kind === "sandbox-exec" || routed.kind === "sandbox-pty") setItems((prev) => [...prev, {
5606
+ kind: "error",
5607
+ id: crypto.randomUUID(),
5608
+ text: "/sandbox is coming soon — run local commands with ! for now."
5609
+ }]);
5610
+ return;
5611
+ }
5146
5612
  history.append(content);
5147
5613
  setInput("");
5148
5614
  composerRef.current?.clear();
@@ -5153,7 +5619,8 @@ function ChatScreen({ agent, conversation }) {
5153
5619
  input,
5154
5620
  pending,
5155
5621
  sendContent,
5156
- history
5622
+ history,
5623
+ runLocalShell
5157
5624
  ]);
5158
5625
  const handleComposerChange = useCallback(() => {
5159
5626
  const composer = composerRef.current;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skydive-cli",
3
- "version": "0.1.0-beta.186",
3
+ "version": "0.1.0-beta.189",
4
4
  "description": "Skydive CLI — manage AI agents from the command line",
5
5
  "homepage": "https://skydive.com",
6
6
  "license": "MIT",