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.
|
|
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-
|
|
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-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
4950
|
-
|
|
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;
|