Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 21 additions & 16 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,29 +83,35 @@ export namespace Agent {
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]

const defaults = Permission.fromConfig({
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
read: {
"*": "allow",
doom_loop: "ask",
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
bash: {
"*": "allow",
"sudo *": "deny",
"dd *": "deny",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
bash: {
"*": "allow",
"sudo *": "deny",
"dd *": "deny",
"mkfs*": "deny",
"chmod *": "deny",
"kill *": "deny",
"rm *": "deny",
"rmdir *": "deny",
"unlink *": "deny",
"find * -delete*": "deny",
},
external_directory: {
"*": "allow",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
})
},
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
})

const user = Permission.fromConfig(cfg.permission ?? {})

Expand Down Expand Up @@ -171,7 +177,6 @@ export namespace Agent {
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { Argv } from "yargs"

type AgentMode = "all" | "primary" | "subagent"

const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"]
const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"]

const AgentCreateCommand = cmd({
command: "create",
Expand Down
54 changes: 32 additions & 22 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { Permission } from "../../permission"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ListTool } from "../../tool/ls"
import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
Expand Down Expand Up @@ -49,6 +48,8 @@ type Inline = {
description?: string
}

type RenderedTool = (Inline & { kind: "inline" }) | (Inline & { kind: "block"; output?: string })

function inline(info: Inline) {
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
Expand All @@ -63,15 +64,20 @@ function block(info: Inline, output?: string) {
}

function fallback(part: ToolPart) {
inline(fallbackInfo(part))
}

function fallbackInfo(part: ToolPart): RenderedTool {
const state = part.state
const input = "input" in state ? state.input : undefined
const title =
("title" in state && state.title ? state.title : undefined) ||
(input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
inline({
return {
kind: "inline",
icon: "⚙",
title: `${part.tool} ${title}`,
})
}
}

function glob(info: ToolProps<typeof GlobTool>) {
Expand Down Expand Up @@ -102,12 +108,28 @@ function grep(info: ToolProps<typeof GrepTool>) {
})
}

function list(info: ToolProps<typeof ListTool>) {
const dir = info.input.path ? normalizePath(info.input.path) : ""
inline({
icon: "→",
title: dir ? `List ${dir}` : "List",
})
function renderTool(info: RenderedTool) {
if (info.kind === "block") {
return block(info, info.output)
}
return inline(info)
}

export function describeToolPartForRun(part: ToolPart): RenderedTool {
try {
if (part.tool === "bash") {
const info = props<typeof BashTool>(part)
return {
kind: "block",
icon: "$",
title: `${info.input.command}`,
output: info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined,
}
}
return fallbackInfo(part)
} catch {
return fallbackInfo(part)
}
}

function read(info: ToolProps<typeof ReadTool>) {
Expand Down Expand Up @@ -191,17 +213,6 @@ function skill(info: ToolProps<typeof SkillTool>) {
})
}

function bash(info: ToolProps<typeof BashTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
block(
{
icon: "$",
title: `${info.input.command}`,
},
output,
)
}

function todo(info: ToolProps<typeof TodoWriteTool>) {
block(
{
Expand Down Expand Up @@ -416,10 +427,9 @@ export const RunCommand = cmd({
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
try {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
if (part.tool === "bash") return renderTool(describeToolPartForRun(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "list") return list(props<typeof ListTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
Expand Down
18 changes: 0 additions & 18 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import { BashTool } from "@/tool/bash"
import type { GlobTool } from "@/tool/glob"
import { TodoWriteTool } from "@/tool/todo"
import type { GrepTool } from "@/tool/grep"
import type { ListTool } from "@/tool/ls"
import type { EditTool } from "@/tool/edit"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
Expand Down Expand Up @@ -1532,9 +1531,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "grep"}>
<Grep {...toolprops} />
</Match>
<Match when={props.part.tool === "list"}>
<List {...toolprops} />
</Match>
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
Expand Down Expand Up @@ -1913,20 +1909,6 @@ function Grep(props: ToolProps<typeof GrepTool>) {
)
}

function List(props: ToolProps<typeof ListTool>) {
const dir = createMemo(() => {
if (props.input.path) {
return normalizePath(props.input.path)
}
return ""
})
return (
<InlineTool icon="→" pending="Listing directory..." complete={props.input.path !== undefined} part={props.part}>
List {dir()}
</InlineTool>
)
}

function WebFetch(props: ToolProps<typeof WebFetchTool>) {
return (
<InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
Expand Down
16 changes: 0 additions & 16 deletions packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,22 +267,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}

if (permission === "list") {
const raw = data.path
const dir = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `List ${normalizePath(dir)}`,
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
</box>
</Show>
),
}
}

if (permission === "bash") {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
Expand Down
1 change: 0 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,6 @@ export namespace Config {
edit: PermissionRule.optional(),
glob: PermissionRule.optional(),
grep: PermissionRule.optional(),
list: PermissionRule.optional(),
bash: PermissionRule.optional(),
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Config } from "./config"
2 changes: 0 additions & 2 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { Account } from "@/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
Expand Down Expand Up @@ -57,7 +56,6 @@ export const AppLayer = Layer.mergeAll(
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as EffectLogger from "./logger"
export { InstanceState } from "./instance-state"
67 changes: 31 additions & 36 deletions packages/opencode/src/file/ripgrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import fs from "fs/promises"
import z from "zod"
import { Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import type { PlatformError } from "effect/PlatformError"
Expand Down Expand Up @@ -220,6 +219,12 @@ export namespace Ripgrep {
return filepath
}

function env() {
return {
RIPGREP_CONFIG_PATH: undefined,
} satisfies NodeJS.ProcessEnv
}

export async function* files(input: {
cwd: string
glob?: string[]
Expand Down Expand Up @@ -251,12 +256,13 @@ export namespace Ripgrep {

const proc = Process.spawn(args, {
cwd: input.cwd,
env: env(),
stdout: "pipe",
stderr: "ignore",
stderr: "pipe",
abort: input.signal,
})

if (!proc.stdout) {
if (!proc.stdout || !proc.stderr) {
throw new Error("Process output not available")
}

Expand All @@ -276,9 +282,12 @@ export namespace Ripgrep {
}

if (buffer) yield buffer
await proc.exited

const exit = await proc.exited
input.signal?.throwIfAborted()
if (exit !== 0) {
const stderr = (await text(proc.stderr)).trim()
throw new Error(stderr || `ripgrep failed with exit code ${exit}`)
}
}

export interface Interface {
Expand All @@ -288,6 +297,7 @@ export namespace Ripgrep {
hidden?: boolean
follow?: boolean
maxDepth?: number
signal?: AbortSignal
}) => Stream.Stream<string, PlatformError>
}

Expand All @@ -296,45 +306,21 @@ export namespace Ripgrep {
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service

const files = Effect.fn("Ripgrep.files")(function* (input: {
const streamFiles = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
signal?: AbortSignal
}) {
const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT" as const,
errno: -2,
path: input.cwd,
}),
)
}

const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}

return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
return Stream.fromAsyncIterable(Ripgrep.files(input), (error) =>
error instanceof Error ? (error as PlatformError) : (new Error(String(error)) as PlatformError),
)
})

return Service.of({
files: (input) => Stream.unwrap(files(input)),
files: (input) => Stream.unwrap(streamFiles(input)),
})
}),
)
Expand Down Expand Up @@ -427,12 +413,21 @@ export namespace Ripgrep {

const result = await Process.text(args, {
cwd: input.cwd,
env: env(),
nothrow: true,
})
if (result.code !== 0) {
if (result.code === 1) {
return []
}

if (result.code !== 0 && result.code !== 2) {
throw new Process.RunFailedError(args, result.code, result.stdout, result.stderr)
}

if (result.code === 2 && !result.text.trim()) {
throw new Process.RunFailedError(args, result.code, result.stdout, result.stderr)
}

// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
Expand Down
Loading
Loading