Merge branch 'dev' into project

This commit is contained in:
Dax Raad
2025-08-30 15:22:48 -04:00
178 changed files with 4250 additions and 6791 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.5.18",
"version": "0.5.29",
"name": "opencode",
"type": "module",
"private": true,
@@ -41,7 +41,7 @@
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
+52 -5
View File
@@ -97,11 +97,11 @@ if (!snapshot) {
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const pkgbuild = [
const binaryPkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='${pkg}'",
"pkgname='opencode-bin'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
@@ -125,11 +125,58 @@ if (!snapshot) {
"",
].join("\n")
for (const pkg of ["opencode-bin"]) {
// Source-based PKGBUILD for opencode
const sourcePkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='opencode'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/sst/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
"conflicts=('opencode-bin')",
"depends=('fzf' 'ripgrep')",
"makedepends=('git' 'bun-bin' 'go')",
"",
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v${version}.tar.gz")`,
`sha256sums=('SKIP')`,
"",
"build() {",
` cd "opencode-\${pkgver}"`,
` bun install`,
" cd packages/tui",
` CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=\${pkgver}" -o tui cmd/opencode/main.go`,
" cd ../opencode",
` bun build --define OPENCODE_TUI_PATH="'$(realpath ../tui/tui)'" --define OPENCODE_VERSION="'\${pkgver}'" --compile --target=bun-linux-x64 --outfile=opencode ./src/index.ts`,
"}",
"",
"package() {",
` cd "opencode-\${pkgver}/packages/opencode"`,
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
for (const [pkg, pkgbuild] of [
["opencode-bin", binaryPkgbuild],
["opencode", sourcePkgbuild],
]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
while (true) {
try {
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
break
} catch (e) {
continue
}
}
await $`cd ./dist/aur-${pkg} && git checkout master`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
+2
View File
@@ -17,6 +17,7 @@ export namespace App {
hostname: z.string(),
git: z.boolean(),
path: z.object({
home: z.string(),
config: z.string(),
data: z.string(),
root: z.string(),
@@ -77,6 +78,7 @@ export namespace App {
},
git: git !== undefined,
path: {
home: os.homedir(),
config: Global.Path.config,
state: Global.Path.state,
data,
+1 -1
View File
@@ -245,7 +245,7 @@ export const AuthLoginCommand = cmd({
}
if (provider === "vercel") {
prompts.log.info("You can create an api key in the dashboard")
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
const key = await prompts.password({
@@ -0,0 +1,20 @@
import { App } from "../../../app/app"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
const AppInfoCommand = cmd({
command: "info",
builder: (yargs) => yargs,
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(JSON.stringify(app, null, 2))
})
},
})
export const AppCommand = cmd({
command: "app",
builder: (yargs) => yargs.command(AppInfoCommand).demandCommand(),
async handler() {},
})
+18 -1
View File
@@ -29,8 +29,25 @@ const FileStatusCommand = cmd({
},
})
const FileListCommand = cmd({
command: "list <path>",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
demandOption: true,
description: "File path to list",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const files = await File.list(args.path)
console.log(JSON.stringify(files, null, 2))
})
},
})
export const FileCommand = cmd({
command: "file",
builder: (yargs) => yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
builder: (yargs) =>
yargs.command(FileReadCommand).command(FileStatusCommand).command(FileListCommand).demandCommand(),
async handler() {},
})
@@ -1,6 +1,7 @@
import { Global } from "../../../global"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { AppCommand } from "./app"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
@@ -11,6 +12,7 @@ export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(AppCommand)
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
+1 -1
View File
@@ -16,7 +16,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
console.log(JSON.stringify(await LSP.diagnostics(), null, 2))
})
},
})
+7 -6
View File
@@ -64,6 +64,11 @@ export const RunCommand = cmd({
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (message.trim().length === 0) {
UI.error("Message cannot be empty")
return
}
await bootstrap({ cwd: process.cwd() }, async () => {
const session = await (async () => {
if (args.continue) {
@@ -171,12 +176,8 @@ export const RunCommand = cmd({
const result = await Session.chat({
sessionID: session.id,
messageID,
...(agent.model
? agent.model
: {
providerID,
modelID,
}),
providerID,
modelID,
agent: agent.name,
parts: [
{
+4 -1
View File
@@ -342,7 +342,10 @@ export namespace Config {
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
tui: TUI.optional().describe("TUI specific settings"),
command: z.record(z.string(), Command).optional(),
command: z
.record(z.string(), Command)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
plugin: z.string().array().optional(),
snapshot: z.boolean().optional(),
share: z
+50 -7
View File
@@ -3,8 +3,9 @@ import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
import * as git from "isomorphic-git"
import { App } from "../app/app"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { Project } from "../project/project"
@@ -25,6 +26,18 @@ export namespace File {
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.openapi({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Event = {
Edited: Bus.event(
"file.edited",
@@ -114,12 +127,8 @@ export namespace File {
.then((x) => x.trim())
if (project.vcs === "git") {
const rel = path.relative(Instance.worktree, full)
const diff = await git.status({
fs,
dir: Instance.worktree,
filepath: rel,
})
if (diff !== "unmodified") {
const diff = await $`git diff ${rel}`.cwd(Instance.worktree).quiet().nothrow().text()
if (diff.trim()) {
const original = await $`git show HEAD:${rel}`.cwd(Instance.worktree).quiet().nothrow().text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
@@ -129,4 +138,38 @@ export namespace File {
}
return { type: "raw", content }
}
export async function list(dir?: string) {
const exclude = [".git", ".DS_Store"]
const app = App.info()
let ignored = (_: string) => false
if (app.git) {
const gitignore = Bun.file(path.join(app.path.root, ".gitignore"))
if (await gitignore.exists()) {
const ig = ignore().add(await gitignore.text())
ignored = ig.ignores.bind(ig)
}
}
const resolved = dir ? path.join(app.path.cwd, dir) : app.path.cwd
const nodes: Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(app.path.cwd, fullPath)
const relativeToRoot = path.relative(app.path.root, fullPath)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: relativePath,
type,
ignored: ignored(type === "directory" ? relativeToRoot + "/" : relativeToRoot),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
}
+1
View File
@@ -139,6 +139,7 @@ export namespace LSP {
s.broken.add(root + server.id)
handle.process.kill()
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})
if (!client) continue
s.clients.push(client)
+33 -2
View File
@@ -148,6 +148,7 @@ export namespace LSPServer {
async spawn(root) {
const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {})
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
if (!(await Bun.file(serverPath).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -164,7 +165,9 @@ export namespace LSPServer {
const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main")
const finalPath = path.join(Global.Path.bin, "vscode-eslint")
if (await Bun.file(finalPath).exists()) {
const stats = await fs.stat(finalPath).catch(() => undefined)
if (stats) {
log.info("removing old eslint installation", { path: finalPath })
await fs.rm(finalPath, { force: true, recursive: true })
}
await fs.rename(extractedPath, finalPath)
@@ -512,7 +515,35 @@ export namespace LSPServer {
export const RustAnalyzer: Info = {
id: "rust",
root: NearestRoot(["Cargo.toml", "Cargo.lock"]),
root: async (file, app) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, app)
if (crateRoot === undefined) {
return undefined
}
let currentDir = crateRoot
while (currentDir !== path.dirname(currentDir)) {
// Stop at filesystem root
const cargoTomlPath = path.join(currentDir, "Cargo.toml")
try {
const cargoTomlContent = await Bun.file(cargoTomlPath).text()
if (cargoTomlContent.includes("[workspace]")) {
return currentDir
}
} catch (err) {
// File doesn't exist or can't be read, continue searching up
}
const parentDir = path.dirname(currentDir)
if (parentDir === currentDir) break // Reached filesystem root
currentDir = parentDir
// Stop if we've gone above the app root
if (!currentDir.startsWith(app.path.root)) break
}
return crateRoot
},
extensions: [".rs"],
async spawn(root) {
const bin = Bun.which("rust-analyzer")
+2 -2
View File
@@ -54,7 +54,7 @@ export namespace MCP {
let lastError: Error | undefined
for (const { name, transport } of transports) {
const client = await experimental_createMCPClient({
name: key,
name: "opencode",
transport,
}).catch((error) => {
lastError = error instanceof Error ? error : new Error(String(error))
@@ -91,7 +91,7 @@ export namespace MCP {
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
const client = await experimental_createMCPClient({
name: key,
name: "opencode",
transport: new StdioClientTransport({
stderr: "ignore",
command: cmd,
+28 -4
View File
@@ -936,6 +936,34 @@ export namespace Server {
)
.get(
"/file",
describeRoute({
description: "List files and directories",
operationId: "file.list",
responses: {
200: {
description: "Files and directories",
content: {
"application/json": {
schema: resolver(File.Node.array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.list(path)
return c.json(content)
},
)
.get(
"/file/content",
describeRoute({
description: "Read a file",
operationId: "file.read",
@@ -964,10 +992,6 @@ export namespace Server {
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
log.info("read file", {
path,
content: content.content,
})
return c.json(content)
},
)
+63 -10
View File
@@ -1,4 +1,5 @@
import path from "path"
import os from "os"
import { spawn } from "child_process"
import { Decimal } from "decimal.js"
import { z, ZodSchema } from "zod"
@@ -721,7 +722,9 @@ export namespace Session {
draft.title = title.trim()
})
})
.catch(() => {})
.catch((error) => {
log.error("failed to generate title", { error, model: small.info.id })
})
}
const agent = await Agent.get(inputAgent)
@@ -866,11 +869,31 @@ export namespace Session {
const execute = item.execute
if (!execute) continue
item.execute = async (args, opts) => {
await Plugin.trigger(
"tool.execute.before",
{
tool: key,
sessionID: input.sessionID,
callID: opts.toolCallId,
},
{
args,
},
)
const result = await execute(args, opts)
const output = result.content
.filter((x: any) => x.type === "text")
.map((x: any) => x.text)
.join("\n\n")
await Plugin.trigger(
"tool.execute.after",
{
tool: key,
sessionID: input.sessionID,
callID: opts.toolCallId,
},
result,
)
return {
output,
@@ -1041,6 +1064,25 @@ export namespace Session {
export type ShellInput = z.infer<typeof ShellInput>
export async function shell(input: ShellInput) {
using abort = lock(input.sessionID)
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
time: {
created: Date.now(),
},
role: "user",
}
await updateMessage(userMsg)
const userPart: MessageV2.Part = {
type: "text",
id: Identifier.ascending("part"),
messageID: userMsg.id,
sessionID: input.sessionID,
text: "The following tool was executed by the user",
synthetic: true,
}
await updatePart(userPart)
const msg: MessageV2.Assistant = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
@@ -1177,14 +1219,22 @@ export namespace Session {
export async function command(input: CommandInput) {
const command = await Command.get(input.command)
const agent = input.agent ?? command.agent ?? "build"
const agent = command.agent ?? input.agent ?? "build"
const fmtModel = (model: { providerID: string; modelID: string }) => `${model.providerID}/${model.modelID}`
const model =
input.model ??
command.model ??
(await Agent.get(agent).then((x) => (x.model ? `${x.model.providerID}/${x.model.modelID}` : undefined))) ??
(await Provider.defaultModel().then((x) => `${x.providerID}/${x.modelID}`))
(command.agent && (await Agent.get(command.agent).then((x) => (x.model ? fmtModel(x.model) : undefined)))) ??
input.model ??
(input.agent && (await Agent.get(input.agent).then((x) => (x.model ? fmtModel(x.model) : undefined)))) ??
fmtModel(await Provider.defaultModel())
let template = command.template.replace("$ARGUMENTS", input.arguments)
// intentionally doing match regex doing bash regex replacements
// this is because bash commands can output "@" references
const fileMatches = template.matchAll(fileRegex)
const bash = Array.from(template.matchAll(bashRegex))
if (bash.length > 0) {
const results = await Promise.all(
@@ -1207,15 +1257,18 @@ export namespace Session {
},
] as ChatInput["parts"]
const matches = template.matchAll(fileRegex)
const app = App.info()
for (const match of matches) {
const file = path.join(app.path.cwd, match[1])
for (const match of fileMatches) {
const filename = match[1]
const filepath = filename.startsWith("~/")
? path.join(os.homedir(), filename.slice(2))
: path.join(app.path.cwd, filename)
parts.push({
type: "file",
url: `file://${file}`,
filename: match[1],
url: `file://${filepath}`,
filename,
mime: "text/plain",
})
}
+3
View File
@@ -109,6 +109,9 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c
gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
EOF
)"
</example>
Important:
- NEVER update the git config