saeeol 1.2.1 → 1.2.3
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/bin/saeeol.cjs +187 -0
- package/npm/bin/saeeol +0 -0
- package/package.json +12 -12
- package/src/cli/cmd/tui/component/dialog/dialog-agent.tsx +32 -0
- package/src/cli/cmd/tui/component/dialog/dialog-command.tsx +190 -0
- package/src/cli/cmd/tui/component/dialog/dialog-console-org.tsx +103 -0
- package/src/cli/cmd/tui/component/dialog/dialog-go-upsell.tsx +159 -0
- package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +86 -0
- package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +238 -0
- package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +343 -0
- package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +103 -0
- package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +301 -0
- package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +35 -0
- package/src/cli/cmd/tui/component/dialog/dialog-skill.tsx +37 -0
- package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +87 -0
- package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +190 -0
- package/src/cli/cmd/tui/component/dialog/dialog-tag.tsx +44 -0
- package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +50 -0
- package/src/cli/cmd/tui/component/dialog/dialog-variant.tsx +39 -0
- package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +200 -0
- package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +81 -0
- package/src/cli/cmd/tui/component/dialog-agent.tsx +1 -32
- package/src/cli/cmd/tui/component/dialog-command.tsx +1 -190
- package/src/cli/cmd/tui/component/dialog-console-org.tsx +1 -103
- package/src/cli/cmd/tui/component/dialog-go-upsell.tsx +1 -159
- package/src/cli/cmd/tui/component/dialog-mcp.tsx +1 -86
- package/src/cli/cmd/tui/component/dialog-model.tsx +1 -238
- package/src/cli/cmd/tui/component/dialog-provider.tsx +1 -343
- package/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +1 -103
- package/src/cli/cmd/tui/component/dialog-session-list.tsx +1 -301
- package/src/cli/cmd/tui/component/dialog-session-rename.tsx +1 -35
- package/src/cli/cmd/tui/component/dialog-skill.tsx +1 -37
- package/src/cli/cmd/tui/component/dialog-stash.tsx +1 -87
- package/src/cli/cmd/tui/component/dialog-status.tsx +1 -190
- package/src/cli/cmd/tui/component/dialog-tag.tsx +1 -44
- package/src/cli/cmd/tui/component/dialog-theme-list.tsx +1 -50
- package/src/cli/cmd/tui/component/dialog-variant.tsx +1 -39
- package/src/cli/cmd/tui/component/dialog-workspace-create.tsx +1 -200
- package/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +1 -81
- package/src/cli/cmd/tui/context/app/args.tsx +15 -0
- package/src/cli/cmd/tui/context/app/directory.ts +15 -0
- package/src/cli/cmd/tui/context/app/editor-zed.ts +281 -0
- package/src/cli/cmd/tui/context/app/editor.ts +425 -0
- package/src/cli/cmd/tui/context/app/helper.tsx +25 -0
- package/src/cli/cmd/tui/context/app/project.tsx +109 -0
- package/src/cli/cmd/tui/context/app/route.tsx +67 -0
- package/src/cli/cmd/tui/context/app/sdk.tsx +142 -0
- package/src/cli/cmd/tui/context/app/sync.tsx +713 -0
- package/src/cli/cmd/tui/context/app/theme.tsx +307 -0
- package/src/cli/cmd/tui/context/app/tui-config.tsx +9 -0
- package/src/cli/cmd/tui/context/args.tsx +1 -15
- package/src/cli/cmd/tui/context/directory.ts +1 -15
- package/src/cli/cmd/tui/context/editor-zed.ts +1 -281
- package/src/cli/cmd/tui/context/editor.ts +1 -425
- package/src/cli/cmd/tui/context/event.ts +1 -45
- package/src/cli/cmd/tui/context/exit.tsx +1 -67
- package/src/cli/cmd/tui/context/helper.tsx +1 -25
- package/src/cli/cmd/tui/context/keybind.tsx +1 -105
- package/src/cli/cmd/tui/context/kv.tsx +1 -76
- package/src/cli/cmd/tui/context/local.tsx +1 -478
- package/src/cli/cmd/tui/context/plugin-keybinds.ts +1 -41
- package/src/cli/cmd/tui/context/project.tsx +1 -109
- package/src/cli/cmd/tui/context/prompt.tsx +1 -18
- package/src/cli/cmd/tui/context/route.tsx +1 -67
- package/src/cli/cmd/tui/context/runtime/event.ts +45 -0
- package/src/cli/cmd/tui/context/runtime/exit.tsx +67 -0
- package/src/cli/cmd/tui/context/runtime/keybind.tsx +105 -0
- package/src/cli/cmd/tui/context/runtime/kv.tsx +76 -0
- package/src/cli/cmd/tui/context/runtime/local.tsx +478 -0
- package/src/cli/cmd/tui/context/runtime/plugin-keybinds.ts +41 -0
- package/src/cli/cmd/tui/context/sdk.tsx +1 -142
- package/src/cli/cmd/tui/context/session/prompt.tsx +18 -0
- package/src/cli/cmd/tui/context/sync.tsx +1 -713
- package/src/cli/cmd/tui/context/theme.tsx +1 -307
- package/src/cli/cmd/tui/context/tui-config.tsx +1 -9
- package/src/tool/apply_patch.ts +1 -334
- package/src/tool/bash.ts +1 -656
- package/src/tool/core/external-directory.ts +55 -0
- package/src/tool/core/invalid.ts +21 -0
- package/src/tool/core/recall.ts +164 -0
- package/src/tool/core/recall.txt +12 -0
- package/src/tool/core/schema.ts +16 -0
- package/src/tool/core/tool.ts +162 -0
- package/src/tool/core/truncate.ts +160 -0
- package/src/tool/core/truncation-dir.ts +4 -0
- package/src/tool/diagnostics.ts +1 -20
- package/src/tool/edit-replacers.ts +1 -288
- package/src/tool/edit-utils.ts +1 -86
- package/src/tool/edit.ts +1 -262
- package/src/tool/external-directory.ts +1 -55
- package/src/tool/file/apply_patch.ts +334 -0
- package/src/tool/file/apply_patch.txt +33 -0
- package/src/tool/file/bash.ts +656 -0
- package/src/tool/file/bash.txt +119 -0
- package/src/tool/file/edit-replacers.ts +288 -0
- package/src/tool/file/edit-utils.ts +86 -0
- package/src/tool/file/edit.ts +262 -0
- package/src/tool/file/edit.txt +10 -0
- package/src/tool/file/read.ts +389 -0
- package/src/tool/file/read.txt +14 -0
- package/src/tool/file/write.ts +114 -0
- package/src/tool/file/write.txt +8 -0
- package/src/tool/glob.ts +1 -115
- package/src/tool/grep.ts +1 -151
- package/src/tool/integration/diagnostics.ts +20 -0
- package/src/tool/integration/lsp.ts +113 -0
- package/src/tool/integration/lsp.txt +24 -0
- package/src/tool/integration/mcp-exa.ts +73 -0
- package/src/tool/integration/package.ts +168 -0
- package/src/tool/integration/registry.ts +375 -0
- package/src/tool/invalid.ts +1 -21
- package/src/tool/lsp.ts +1 -113
- package/src/tool/mcp-exa.ts +1 -73
- package/src/tool/package.ts +1 -168
- package/src/tool/plan.ts +1 -30
- package/src/tool/question.ts +1 -52
- package/src/tool/read.ts +1 -389
- package/src/tool/recall.ts +1 -164
- package/src/tool/registry.ts +1 -375
- package/src/tool/schema.ts +1 -16
- package/src/tool/search/glob.ts +115 -0
- package/src/tool/search/glob.txt +6 -0
- package/src/tool/search/grep.ts +151 -0
- package/src/tool/search/grep.txt +8 -0
- package/src/tool/search/warpgrep.ts +107 -0
- package/src/tool/search/warpgrep.txt +10 -0
- package/src/tool/search/webfetch.ts +202 -0
- package/src/tool/search/webfetch.txt +13 -0
- package/src/tool/search/websearch.ts +71 -0
- package/src/tool/search/websearch.txt +14 -0
- package/src/tool/skill.ts +1 -91
- package/src/tool/task.ts +1 -197
- package/src/tool/todo.ts +1 -62
- package/src/tool/tool.ts +1 -162
- package/src/tool/truncate.ts +1 -160
- package/src/tool/truncation-dir.ts +1 -4
- package/src/tool/warpgrep.ts +1 -107
- package/src/tool/webfetch.ts +1 -202
- package/src/tool/websearch.ts +1 -71
- package/src/tool/workflow/plan-enter.txt +14 -0
- package/src/tool/workflow/plan-exit.txt +13 -0
- package/src/tool/workflow/plan.ts +30 -0
- package/src/tool/workflow/question.ts +52 -0
- package/src/tool/workflow/question.txt +11 -0
- package/src/tool/workflow/skill.ts +91 -0
- package/src/tool/workflow/skill.txt +5 -0
- package/src/tool/workflow/task.ts +197 -0
- package/src/tool/workflow/task.txt +57 -0
- package/src/tool/workflow/todo.ts +62 -0
- package/src/tool/workflow/todowrite.txt +167 -0
- package/src/tool/write.ts +1 -114
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import { PositiveInt } from "@/util/schema"
|
|
3
|
+
import os from "os"
|
|
4
|
+
import { createWriteStream } from "node:fs"
|
|
5
|
+
import * as Tool from "../core/tool"
|
|
6
|
+
import path from "path"
|
|
7
|
+
import DESCRIPTION from "./bash.txt"
|
|
8
|
+
import * as Log from "@saeeol/core/util/log"
|
|
9
|
+
import { containsPath, type InstanceContext } from "../../project/instance-context"
|
|
10
|
+
import { lazy } from "@/util/lazy"
|
|
11
|
+
import { Language, type Node } from "web-tree-sitter"
|
|
12
|
+
|
|
13
|
+
import { AppFileSystem } from "@saeeol/core/filesystem"
|
|
14
|
+
import { fileURLToPath } from "url"
|
|
15
|
+
import { Config } from "@/config/config"
|
|
16
|
+
import { Flag } from "@saeeol/core/flag/flag"
|
|
17
|
+
import { Global } from "@saeeol/core/global"
|
|
18
|
+
import { Shell } from "@/shell/shell"
|
|
19
|
+
|
|
20
|
+
import { BashArity } from "@/permission/arity"
|
|
21
|
+
import * as Truncate from "../core/truncate"
|
|
22
|
+
import { Plugin } from "@/plugin"
|
|
23
|
+
import { normalizeUrls } from "@/saeeol/util/url"
|
|
24
|
+
import { Effect, Stream } from "effect"
|
|
25
|
+
import { ChildProcess } from "effect/unstable/process"
|
|
26
|
+
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
|
27
|
+
import { InstanceState } from "@/effect/instance-state"
|
|
28
|
+
|
|
29
|
+
const MAX_METADATA_LENGTH = 30_000
|
|
30
|
+
const DEFAULT_TIMEOUT = Flag.SAEEOL_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
|
|
31
|
+
const CWD = new Set(["cd", "push-location", "set-location"])
|
|
32
|
+
const FILES = new Set([
|
|
33
|
+
...CWD,
|
|
34
|
+
"rm",
|
|
35
|
+
"cp",
|
|
36
|
+
"mv",
|
|
37
|
+
"mkdir",
|
|
38
|
+
"touch",
|
|
39
|
+
"chmod",
|
|
40
|
+
"chown",
|
|
41
|
+
"cat",
|
|
42
|
+
// Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir
|
|
43
|
+
// already hit the entries above, and alias normalization should happen in one
|
|
44
|
+
// place later so we do not risk double-prompting.
|
|
45
|
+
"get-content",
|
|
46
|
+
"set-content",
|
|
47
|
+
"add-content",
|
|
48
|
+
"copy-item",
|
|
49
|
+
"move-item",
|
|
50
|
+
"remove-item",
|
|
51
|
+
"new-item",
|
|
52
|
+
"rename-item",
|
|
53
|
+
])
|
|
54
|
+
const READ = new Set(["cat", "get-content"])
|
|
55
|
+
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
|
|
56
|
+
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
|
|
57
|
+
|
|
58
|
+
export const Parameters = Schema.Struct({
|
|
59
|
+
command: Schema.String.annotate({ description: "The command to execute" }),
|
|
60
|
+
timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }),
|
|
61
|
+
workdir: Schema.optional(Schema.String).annotate({
|
|
62
|
+
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
|
|
63
|
+
}),
|
|
64
|
+
description: Schema.optional(Schema.String).annotate({
|
|
65
|
+
description:
|
|
66
|
+
"Recommended: a clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
|
|
67
|
+
}),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
type Part = {
|
|
71
|
+
type: string
|
|
72
|
+
text: string
|
|
73
|
+
}
|
|
74
|
+
type Access = "read" | "unknown"
|
|
75
|
+
|
|
76
|
+
type Scan = {
|
|
77
|
+
dirs: Set<string>
|
|
78
|
+
patterns: Set<string>
|
|
79
|
+
always: Set<string>
|
|
80
|
+
access: Access
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Chunk = {
|
|
84
|
+
text: string
|
|
85
|
+
size: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const log = Log.create({ service: "bash-tool" })
|
|
89
|
+
|
|
90
|
+
const resolveWasm = (asset: string) => {
|
|
91
|
+
if (asset.startsWith("file://")) return fileURLToPath(asset)
|
|
92
|
+
if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
|
|
93
|
+
const url = new URL(asset, import.meta.url)
|
|
94
|
+
return fileURLToPath(url)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parts(node: Node) {
|
|
98
|
+
const out: Part[] = []
|
|
99
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
100
|
+
const child = node.child(i)
|
|
101
|
+
if (!child) continue
|
|
102
|
+
if (child.type === "command_elements") {
|
|
103
|
+
for (let j = 0; j < child.childCount; j++) {
|
|
104
|
+
const item = child.child(j)
|
|
105
|
+
if (!item || item.type === "command_argument_sep" || item.type === "redirection") continue
|
|
106
|
+
out.push({ type: item.type, text: item.text })
|
|
107
|
+
}
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
child.type !== "command_name" &&
|
|
112
|
+
child.type !== "command_name_expr" &&
|
|
113
|
+
child.type !== "word" &&
|
|
114
|
+
child.type !== "string" &&
|
|
115
|
+
child.type !== "raw_string" &&
|
|
116
|
+
child.type !== "concatenation"
|
|
117
|
+
) {
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
out.push({ type: child.type, text: child.text })
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function source(node: Node) {
|
|
126
|
+
return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim()
|
|
127
|
+
}
|
|
128
|
+
function access(cmd: string, node: Node): Access {
|
|
129
|
+
if (!READ.has(cmd)) return "unknown"
|
|
130
|
+
if (node.parent?.type === "redirected_statement") return "unknown"
|
|
131
|
+
return "read"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function commands(node: Node) {
|
|
135
|
+
return node.descendantsOfType("command").filter((child): child is Node => Boolean(child))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function unquote(text: string) {
|
|
139
|
+
if (text.length < 2) return text
|
|
140
|
+
const first = text[0]
|
|
141
|
+
const last = text[text.length - 1]
|
|
142
|
+
if ((first === '"' || first === "'") && first === last) return text.slice(1, -1)
|
|
143
|
+
return text
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function home(text: string) {
|
|
147
|
+
if (text === "~") return os.homedir()
|
|
148
|
+
if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2))
|
|
149
|
+
return text
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function envValue(key: string) {
|
|
153
|
+
if (process.platform !== "win32") return process.env[key]
|
|
154
|
+
const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase())
|
|
155
|
+
return name ? process.env[name] : undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function auto(key: string, cwd: string, shell: string) {
|
|
159
|
+
const name = key.toUpperCase()
|
|
160
|
+
if (name === "HOME") return os.homedir()
|
|
161
|
+
if (name === "PWD") return cwd
|
|
162
|
+
if (name === "PSHOME") return path.dirname(shell)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function expand(text: string, cwd: string, shell: string) {
|
|
166
|
+
const out = unquote(text)
|
|
167
|
+
.replace(/\$\{env:([^}]+)\}/gi, (_, key: string) => envValue(key) || "")
|
|
168
|
+
.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key: string) => envValue(key) || "")
|
|
169
|
+
.replace(/\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi, (_, key: string) => auto(key, cwd, shell) || "")
|
|
170
|
+
return home(out)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function provider(text: string) {
|
|
174
|
+
const match = text.match(/^([A-Za-z]+)::(.*)$/)
|
|
175
|
+
if (match) {
|
|
176
|
+
if (match[1].toLowerCase() !== "filesystem") return
|
|
177
|
+
return match[2]
|
|
178
|
+
}
|
|
179
|
+
const prefix = text.match(/^([A-Za-z]+):(.*)$/)
|
|
180
|
+
if (!prefix) return text
|
|
181
|
+
if (prefix[1].length === 1) return text
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function dynamic(text: string, ps: boolean) {
|
|
186
|
+
if (text.startsWith("(") || text.startsWith("@(")) return true
|
|
187
|
+
if (text.includes("$(") || text.includes("${") || text.includes("`")) return true
|
|
188
|
+
if (ps) return /\$(?!env:)/i.test(text)
|
|
189
|
+
return text.includes("$")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function prefix(text: string) {
|
|
193
|
+
const match = /[?*[]/.exec(text)
|
|
194
|
+
if (!match) return text
|
|
195
|
+
if (match.index === 0) return
|
|
196
|
+
return text.slice(0, match.index)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function pathArgs(list: Part[], ps: boolean) {
|
|
200
|
+
if (!ps) {
|
|
201
|
+
return list
|
|
202
|
+
.slice(1)
|
|
203
|
+
.filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+")))
|
|
204
|
+
.map((item) => item.text)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const out: string[] = []
|
|
208
|
+
let want = false
|
|
209
|
+
for (const item of list.slice(1)) {
|
|
210
|
+
if (want) {
|
|
211
|
+
out.push(item.text)
|
|
212
|
+
want = false
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (item.type === "command_parameter") {
|
|
216
|
+
const flag = item.text.toLowerCase()
|
|
217
|
+
if (SWITCHES.has(flag)) continue
|
|
218
|
+
want = FLAGS.has(flag)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
out.push(item.text)
|
|
222
|
+
}
|
|
223
|
+
return out
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function preview(text: string) {
|
|
227
|
+
if (text.length <= MAX_METADATA_LENGTH) return text
|
|
228
|
+
return "...\n\n" + text.slice(-MAX_METADATA_LENGTH)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function tail(text: string, maxLines: number, maxBytes: number) {
|
|
232
|
+
const lines = text.split("\n")
|
|
233
|
+
if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) {
|
|
234
|
+
return {
|
|
235
|
+
text,
|
|
236
|
+
cut: false,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const out: string[] = []
|
|
241
|
+
let bytes = 0
|
|
242
|
+
for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
|
243
|
+
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
|
244
|
+
if (bytes + size > maxBytes) {
|
|
245
|
+
if (out.length === 0) {
|
|
246
|
+
const buf = Buffer.from(lines[i], "utf-8")
|
|
247
|
+
let start = buf.length - maxBytes
|
|
248
|
+
if (start < 0) start = 0
|
|
249
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++
|
|
250
|
+
out.unshift(buf.subarray(start).toString("utf-8"))
|
|
251
|
+
}
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
out.unshift(lines[i])
|
|
255
|
+
bytes += size
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
text: out.join("\n"),
|
|
259
|
+
cut: true,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
|
|
264
|
+
const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
|
|
265
|
+
if (!tree) throw new Error("Failed to parse command")
|
|
266
|
+
return tree
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan, command: string) {
|
|
270
|
+
if (scan.dirs.size > 0) {
|
|
271
|
+
const globs = Array.from(scan.dirs).map((dir) => {
|
|
272
|
+
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
|
|
273
|
+
return path.join(dir, "*")
|
|
274
|
+
})
|
|
275
|
+
yield* ctx.ask({
|
|
276
|
+
permission: "external_directory",
|
|
277
|
+
patterns: globs,
|
|
278
|
+
always: globs,
|
|
279
|
+
metadata: scan.access === "read" ? { command, access: "read" } : {},
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (scan.patterns.size === 0) return
|
|
284
|
+
yield* ctx.ask({
|
|
285
|
+
permission: "bash",
|
|
286
|
+
patterns: Array.from(scan.patterns),
|
|
287
|
+
always: Array.from(scan.always),
|
|
288
|
+
metadata: { command: normalizeUrls(command) },
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
|
293
|
+
if (process.platform === "win32" && Shell.ps(shell)) {
|
|
294
|
+
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
|
|
295
|
+
cwd,
|
|
296
|
+
env,
|
|
297
|
+
stdin: "ignore",
|
|
298
|
+
detached: false,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return ChildProcess.make(command, [], {
|
|
303
|
+
shell,
|
|
304
|
+
cwd,
|
|
305
|
+
env,
|
|
306
|
+
stdin: "ignore",
|
|
307
|
+
detached: process.platform !== "win32",
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
const parser = lazy(async () => {
|
|
311
|
+
const { Parser } = await import("web-tree-sitter")
|
|
312
|
+
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
|
313
|
+
with: { type: "wasm" },
|
|
314
|
+
})
|
|
315
|
+
const treePath = resolveWasm(treeWasm)
|
|
316
|
+
await Parser.init({
|
|
317
|
+
locateFile() {
|
|
318
|
+
return treePath
|
|
319
|
+
},
|
|
320
|
+
})
|
|
321
|
+
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
|
|
322
|
+
with: { type: "wasm" },
|
|
323
|
+
})
|
|
324
|
+
const { default: psWasm } = await import("tree-sitter-powershell/tree-sitter-powershell.wasm" as string, {
|
|
325
|
+
with: { type: "wasm" },
|
|
326
|
+
})
|
|
327
|
+
const bashPath = resolveWasm(bashWasm)
|
|
328
|
+
const psPath = resolveWasm(psWasm)
|
|
329
|
+
const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
|
|
330
|
+
const bash = new Parser()
|
|
331
|
+
bash.setLanguage(bashLanguage)
|
|
332
|
+
const ps = new Parser()
|
|
333
|
+
ps.setLanguage(psLanguage)
|
|
334
|
+
return { bash, ps }
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// TODO: we may wanna rename this tool so it works better on other shells
|
|
338
|
+
export const BashTool = Tool.define(
|
|
339
|
+
"bash",
|
|
340
|
+
Effect.gen(function* () {
|
|
341
|
+
const config = yield* Config.Service
|
|
342
|
+
const spawner = yield* ChildProcessSpawner
|
|
343
|
+
const fs = yield* AppFileSystem.Service
|
|
344
|
+
const trunc = yield* Truncate.Service
|
|
345
|
+
const plugin = yield* Plugin.Service
|
|
346
|
+
|
|
347
|
+
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
|
|
348
|
+
const lines = yield* spawner
|
|
349
|
+
.lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]))
|
|
350
|
+
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
351
|
+
const file = lines[0]?.trim()
|
|
352
|
+
if (!file) return
|
|
353
|
+
return AppFileSystem.normalizePath(file)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) {
|
|
357
|
+
if (process.platform === "win32") {
|
|
358
|
+
if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
|
|
359
|
+
const file = yield* cygpath(shell, text)
|
|
360
|
+
if (file) return file
|
|
361
|
+
}
|
|
362
|
+
return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
|
|
363
|
+
}
|
|
364
|
+
return path.resolve(root, text)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
|
|
368
|
+
const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
|
|
369
|
+
const file = text && prefix(text)
|
|
370
|
+
if (!file || dynamic(file, ps)) return
|
|
371
|
+
const next = ps ? provider(file) : file
|
|
372
|
+
if (!next) return
|
|
373
|
+
return yield* resolvePath(next, cwd, shell)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const collect = Effect.fn("BashTool.collect")(function* (
|
|
377
|
+
root: Node,
|
|
378
|
+
cwd: string,
|
|
379
|
+
ps: boolean,
|
|
380
|
+
shell: string,
|
|
381
|
+
instance: InstanceContext,
|
|
382
|
+
) {
|
|
383
|
+
const scan: Scan = {
|
|
384
|
+
dirs: new Set<string>(),
|
|
385
|
+
patterns: new Set<string>(),
|
|
386
|
+
always: new Set<string>(),
|
|
387
|
+
access: "read",
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const nodes = commands(root)
|
|
391
|
+
if (root.descendantsOfType("file_redirect").length > 0) scan.access = "unknown"
|
|
392
|
+
if (nodes.some((node) => !READ.has((ps ? parts(node)[0]?.text.toLowerCase() : parts(node)[0]?.text) ?? ""))) {
|
|
393
|
+
scan.access = "unknown"
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (const node of nodes) {
|
|
397
|
+
const command = parts(node)
|
|
398
|
+
const tokens = command.map((item) => item.text)
|
|
399
|
+
const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
|
|
400
|
+
|
|
401
|
+
if (cmd && FILES.has(cmd)) {
|
|
402
|
+
const kind = access(cmd, node)
|
|
403
|
+
for (const arg of pathArgs(command, ps)) {
|
|
404
|
+
const resolved = yield* argPath(arg, cwd, ps, shell)
|
|
405
|
+
log.info("resolved path", { arg, resolved })
|
|
406
|
+
if (!resolved || containsPath(resolved, instance)) continue
|
|
407
|
+
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
|
|
408
|
+
scan.dirs.add(dir)
|
|
409
|
+
if (kind !== "read") scan.access = "unknown"
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (tokens.length && (!cmd || !CWD.has(cmd))) {
|
|
414
|
+
scan.patterns.add(source(node))
|
|
415
|
+
scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return scan
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
|
|
423
|
+
const extra = yield* plugin.trigger(
|
|
424
|
+
"shell.env",
|
|
425
|
+
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
|
|
426
|
+
{ env: {} },
|
|
427
|
+
)
|
|
428
|
+
return {
|
|
429
|
+
...process.env,
|
|
430
|
+
...extra.env,
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
const run = Effect.fn("BashTool.run")(function* (
|
|
435
|
+
input: {
|
|
436
|
+
shell: string
|
|
437
|
+
command: string
|
|
438
|
+
cwd: string
|
|
439
|
+
env: NodeJS.ProcessEnv
|
|
440
|
+
timeout: number
|
|
441
|
+
description: string
|
|
442
|
+
},
|
|
443
|
+
ctx: Tool.Context,
|
|
444
|
+
) {
|
|
445
|
+
const limits = yield* trunc.limits()
|
|
446
|
+
const keep = limits.maxBytes * 2
|
|
447
|
+
let full = ""
|
|
448
|
+
let last = ""
|
|
449
|
+
const list: Chunk[] = []
|
|
450
|
+
let used = 0
|
|
451
|
+
let file = ""
|
|
452
|
+
let sink: ReturnType<typeof createWriteStream> | undefined
|
|
453
|
+
let cut = false
|
|
454
|
+
let expired = false
|
|
455
|
+
let aborted = false
|
|
456
|
+
|
|
457
|
+
yield* ctx.metadata({
|
|
458
|
+
metadata: {
|
|
459
|
+
output: "",
|
|
460
|
+
description: input.description,
|
|
461
|
+
},
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
const code: number | null = yield* Effect.scoped(
|
|
465
|
+
Effect.gen(function* () {
|
|
466
|
+
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
|
|
467
|
+
|
|
468
|
+
yield* Effect.forkScoped(
|
|
469
|
+
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
|
|
470
|
+
const size = Buffer.byteLength(chunk, "utf-8")
|
|
471
|
+
list.push({ text: chunk, size })
|
|
472
|
+
used += size
|
|
473
|
+
while (used > keep && list.length > 1) {
|
|
474
|
+
const item = list.shift()
|
|
475
|
+
if (!item) break
|
|
476
|
+
used -= item.size
|
|
477
|
+
cut = true
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
last = preview(last + chunk)
|
|
481
|
+
|
|
482
|
+
if (file) {
|
|
483
|
+
sink?.write(chunk)
|
|
484
|
+
} else {
|
|
485
|
+
full += chunk
|
|
486
|
+
if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
|
|
487
|
+
return trunc.write(full).pipe(
|
|
488
|
+
Effect.andThen((next) =>
|
|
489
|
+
Effect.sync(() => {
|
|
490
|
+
file = next
|
|
491
|
+
cut = true
|
|
492
|
+
sink = createWriteStream(next, { flags: "a" })
|
|
493
|
+
full = ""
|
|
494
|
+
}),
|
|
495
|
+
),
|
|
496
|
+
Effect.andThen(
|
|
497
|
+
ctx.metadata({
|
|
498
|
+
metadata: {
|
|
499
|
+
output: last,
|
|
500
|
+
description: input.description,
|
|
501
|
+
},
|
|
502
|
+
}),
|
|
503
|
+
),
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return ctx.metadata({
|
|
509
|
+
metadata: {
|
|
510
|
+
output: last,
|
|
511
|
+
description: input.description,
|
|
512
|
+
},
|
|
513
|
+
})
|
|
514
|
+
}),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
const abort = Effect.callback<void>((resume) => {
|
|
518
|
+
if (ctx.abort.aborted) return resume(Effect.void)
|
|
519
|
+
const handler = () => resume(Effect.void)
|
|
520
|
+
ctx.abort.addEventListener("abort", handler, { once: true })
|
|
521
|
+
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
const timeout = Effect.sleep(`${input.timeout + 100} millis`)
|
|
525
|
+
|
|
526
|
+
const exit = yield* Effect.raceAll([
|
|
527
|
+
handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
|
|
528
|
+
abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
|
|
529
|
+
timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
|
|
530
|
+
])
|
|
531
|
+
|
|
532
|
+
if (exit.kind === "abort") {
|
|
533
|
+
aborted = true
|
|
534
|
+
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
|
|
535
|
+
}
|
|
536
|
+
if (exit.kind === "timeout") {
|
|
537
|
+
expired = true
|
|
538
|
+
yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return exit.kind === "exit" ? exit.code : null
|
|
542
|
+
}),
|
|
543
|
+
).pipe(Effect.orDie)
|
|
544
|
+
|
|
545
|
+
const meta: string[] = []
|
|
546
|
+
if (expired) {
|
|
547
|
+
meta.push(
|
|
548
|
+
`bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`,
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
if (aborted) meta.push("User aborted the command")
|
|
552
|
+
const raw = list.map((item) => item.text).join("")
|
|
553
|
+
const end = tail(raw, limits.maxLines, limits.maxBytes)
|
|
554
|
+
if (end.cut) cut = true
|
|
555
|
+
if (!file && end.cut) {
|
|
556
|
+
file = yield* trunc.write(raw)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let output = end.text
|
|
560
|
+
if (!output) output = "(no output)"
|
|
561
|
+
|
|
562
|
+
if (cut && file) {
|
|
563
|
+
output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (meta.length > 0) {
|
|
567
|
+
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
|
|
568
|
+
}
|
|
569
|
+
if (sink) {
|
|
570
|
+
const stream = sink
|
|
571
|
+
yield* Effect.promise(
|
|
572
|
+
() =>
|
|
573
|
+
new Promise<void>((resolve) => {
|
|
574
|
+
stream.end(() => resolve())
|
|
575
|
+
stream.on("error", () => resolve())
|
|
576
|
+
}),
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
title: input.description,
|
|
582
|
+
metadata: {
|
|
583
|
+
output: last || preview(output),
|
|
584
|
+
exit: code,
|
|
585
|
+
description: input.description,
|
|
586
|
+
truncated: cut,
|
|
587
|
+
...(cut && file ? { outputPath: file } : {}),
|
|
588
|
+
},
|
|
589
|
+
output,
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
return () =>
|
|
594
|
+
Effect.gen(function* () {
|
|
595
|
+
const cfg = yield* config.get()
|
|
596
|
+
const shell = Shell.acceptable(cfg.shell)
|
|
597
|
+
const name = Shell.name(shell)
|
|
598
|
+
const chain =
|
|
599
|
+
name === "powershell"
|
|
600
|
+
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
|
601
|
+
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
|
602
|
+
log.info("bash tool using shell", { shell })
|
|
603
|
+
|
|
604
|
+
const limits = yield* trunc.limits()
|
|
605
|
+
const instance = yield* InstanceState.context
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
description: DESCRIPTION.replaceAll("${directory}", instance.directory)
|
|
609
|
+
.replaceAll("${tmp}", Global.Path.tmp)
|
|
610
|
+
.replaceAll("${os}", process.platform)
|
|
611
|
+
.replaceAll("${shell}", name)
|
|
612
|
+
.replaceAll("${chaining}", chain)
|
|
613
|
+
.replaceAll("${maxLines}", String(limits.maxLines))
|
|
614
|
+
.replaceAll("${maxBytes}", String(limits.maxBytes)),
|
|
615
|
+
parameters: Parameters,
|
|
616
|
+
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
|
617
|
+
Effect.gen(function* () {
|
|
618
|
+
const executeInstance = yield* InstanceState.context
|
|
619
|
+
const cwd = params.workdir
|
|
620
|
+
? yield* resolvePath(params.workdir, executeInstance.directory, shell)
|
|
621
|
+
: executeInstance.directory
|
|
622
|
+
if (params.timeout !== undefined && params.timeout < 0) {
|
|
623
|
+
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
|
624
|
+
}
|
|
625
|
+
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
|
626
|
+
const ps = Shell.ps(shell)
|
|
627
|
+
yield* Effect.scoped(
|
|
628
|
+
Effect.gen(function* () {
|
|
629
|
+
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
|
|
630
|
+
Effect.sync(() => tree.delete()),
|
|
631
|
+
)
|
|
632
|
+
const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance)
|
|
633
|
+
if (!containsPath(cwd, executeInstance)) {
|
|
634
|
+
scan.dirs.add(cwd)
|
|
635
|
+
scan.access = "unknown"
|
|
636
|
+
}
|
|
637
|
+
yield* ask(ctx, scan, params.command)
|
|
638
|
+
}),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
return yield* run(
|
|
642
|
+
{
|
|
643
|
+
shell,
|
|
644
|
+
command: params.command,
|
|
645
|
+
cwd,
|
|
646
|
+
env: yield* shellEnv(ctx, cwd),
|
|
647
|
+
timeout,
|
|
648
|
+
description: params.description ?? params.command,
|
|
649
|
+
},
|
|
650
|
+
ctx,
|
|
651
|
+
)
|
|
652
|
+
}),
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
}),
|
|
656
|
+
)
|