Skip to content
Open
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
8 changes: 6 additions & 2 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,10 +539,14 @@ export const RunCommand = cmd({
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const patterns = permission.patterns
const desc =
permission.permission === ".opencode"
? `.opencode location: ${patterns.map((x) => path.resolve(x)).join(", ")}`
: patterns.join(", ")
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${desc}); auto-rejecting`,
)
await sdk.permission.reply({
requestID: permission.id,
Expand Down
19 changes: 17 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {

const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
const patterns = (props.request.patterns ?? []).filter((p: unknown): p is string => typeof p === "string")

return {
icon: "←",
Expand All @@ -378,14 +378,29 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>Patterns</text>
<box>
<For each={patterns}>{(p) => <text fg={theme.text}>{"- " + p}</text>}</For>
<For each={patterns}>{(p: string) => <text fg={theme.text}>{"- " + p}</text>}</For>
</box>
</box>
</Show>
),
}
}

if (permission === ".opencode") {
const meta = props.request.metadata ?? {}
const raw = typeof meta["path"] === "string" ? meta["path"] : props.request.patterns?.[0]
const dir = raw ? path.resolve(raw) : ""
return {
icon: "⚙",
title: "Allow .opencode config",
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>{dir}</text>
</box>
),
}
}

if (permission === "doom_loop") {
return {
icon: "⟳",
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,7 @@ export namespace SessionPrompt {
for (const item of await ToolRegistry.tools(
{ modelID: input.model.api.id, providerID: input.model.providerID },
input.agent,
input.session.id,
)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
Expand Down
119 changes: 95 additions & 24 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,36 +30,104 @@ import { Truncate } from "./truncation"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import { PermissionNext } from "@/permission/next"
import { Global } from "@/global"

export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })

export const state = Instance.state(async () => {
const custom = [] as Tool.Info[]

const matches = await Config.directories().then((dirs) =>
dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
),
)
if (matches.length) await Config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = await import(pathToFileURL(match).href)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
return {
custom: [] as Tool.Info[],
loaded: false,
loading: undefined as Promise<void> | undefined,
}
})

const plugins = await Plugin.list()
for (const plugin of plugins) {
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
function project(dir: string) {
const root = path.resolve(Instance.worktree)
const cur = path.resolve(dir)
if (!dir.endsWith(".opencode")) return false
if (!cur.startsWith(root + path.sep)) return false
if (dir === Flag.OPENCODE_CONFIG_DIR) return false
if (dir === Global.Path.config) return false
return cur !== path.join(Global.Path.home, ".opencode")
}

return { custom }
})
async function load(sessionID?: string) {
const s = await state()
if (s.loaded) return
if (s.loading) return s.loading

s.loading = Config.directories()
.then((dirs) =>
dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }).map(
(file) => ({
dir,
file,
}),
),
),
)
.then(async (entries) => {
if (entries.length) await Config.waitForDependencies()
const gate = new Map<string, boolean>()

for (const item of entries) {
if (project(item.dir)) {
const ok = gate.get(item.dir)
if (ok === false) continue
if (ok !== true) {
if (!sessionID) {
log.warn("skipping project custom tools", {
path: item.dir,
reason: "no active session for permission prompt",
})
gate.set(item.dir, false)
continue
}
const err = await PermissionNext.ask({
permission: ".opencode",
patterns: [item.dir],
always: [item.dir],
sessionID,
metadata: {
path: item.dir,
file: item.file,
},
ruleset: [],
}).catch((x) => x)
if (err instanceof Error) {
log.warn("project custom tools denied", { path: item.dir })
gate.set(item.dir, false)
continue
}
gate.set(item.dir, true)
}
}

const namespace = path.basename(item.file, path.extname(item.file))
const mod = await import(pathToFileURL(item.file).href)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
s.custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}

const plugins = await Plugin.list()
for (const plugin of plugins) {
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
s.custom.push(fromPlugin(id, def))
}
}
s.loaded = true
})
.finally(() => {
s.loading = undefined
})

return s.loading
}

function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
Expand All @@ -86,6 +154,7 @@ export namespace ToolRegistry {
}

export async function register(tool: Tool.Info) {
await load()
const { custom } = await state()
const idx = custom.findIndex((t) => t.id === tool.id)
if (idx >= 0) {
Expand All @@ -95,7 +164,8 @@ export namespace ToolRegistry {
custom.push(tool)
}

async function all(): Promise<Tool.Info[]> {
async function all(sessionID?: string): Promise<Tool.Info[]> {
await load(sessionID)
const custom = await state().then((x) => x.custom)
const config = await Config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
Expand Down Expand Up @@ -134,8 +204,9 @@ export namespace ToolRegistry {
modelID: string
},
agent?: Agent.Info,
sessionID?: string,
) {
const tools = await all()
const tools = await all(sessionID)
const result = await Promise.all(
tools
.filter((t) => {
Expand Down
Loading