Merge branch 'dev' into opentui

This commit is contained in:
Dax Raad 2025-09-27 04:46:30 -04:00
commit 5e5293d98c
58 changed files with 913 additions and 880 deletions

32
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: test
on:
push:
branches-ignore:
- production
pull_request:
branches-ignore:
- production
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.2.21
- name: run
run: |
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
bun install
bun turbo test
env:
CI: true

View file

@ -90,3 +90,4 @@
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |

View file

@ -12,7 +12,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "0.11.6",
"version": "0.12.1",
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "workspace:*",
@ -60,7 +60,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "0.11.6",
"version": "0.12.1",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@opencode/console-resource": "workspace:*",
@ -77,7 +77,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "0.11.6",
"version": "0.12.1",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@ -103,7 +103,7 @@
},
"packages/console/scripts": {
"name": "@opencode/console-scripts",
"version": "0.11.6",
"version": "0.12.1",
"dependencies": {
"@opencode/console-core": "workspace:*",
"tsx": "4.20.5",
@ -115,7 +115,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "0.11.6",
"version": "0.12.1",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@ -130,7 +130,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.11.6",
"version": "0.12.1",
"bin": {
"opencode": "./bin/opencode",
},
@ -189,7 +189,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "0.11.6",
"version": "0.12.1",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@ -201,7 +201,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "0.11.6",
"version": "0.12.1",
"dependencies": {
"@hey-api/openapi-ts": "0.82.5",
},
@ -212,7 +212,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "0.11.6",
"version": "0.12.1",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View file

@ -1,4 +1,3 @@
import { WebhookEndpoint } from "pulumi-stripe"
import { domain } from "./stage"
////////////////
@ -68,7 +67,7 @@ export const auth = new sst.cloudflare.Worker("AuthApi", {
// GATEWAY
////////////////
export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", {
url: $interpolate`https://${domain}/stripe/webhook`,
enabledEvents: [
"checkout.session.async_payment_failed",

View file

@ -3,3 +3,11 @@ export const domain = (() => {
if ($app.stage === "dev") return "dev.opencode.ai"
return `${$app.stage}.dev.opencode.ai`
})()
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
// new cloudflare.RegionalHostname("RegionalHostname", {
// hostname: domain,
// regionKey: "us",
// zoneId: zoneID,
// })

View file

@ -5,7 +5,7 @@
"type": "module",
"packageManager": "bun@1.2.21",
"scripts": {
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun turbo typecheck",
"prepare": "husky"
},

View file

@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "0.11.6",
"version": "0.12.1",
"description": "",
"type": "module",
"scripts": {

View file

@ -6,7 +6,6 @@
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"lib": ["DOM", "DOM.Iterable"],
"customConditions": ["development"],
"paths": {
"@/*": ["./src/*"]
}

View file

@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "0.11.6"
"version": "0.12.1"
},
"dependencies": {
"@ibm/plex": "6.4.1",

View file

@ -15,6 +15,7 @@ export function useAuthSession() {
return useSession<AuthSession>({
password: "0".repeat(32),
name: "auth",
maxAge: 60 * 60 * 24 * 365,
cookie: {
secure: false,
httpOnly: true,

View file

@ -10,6 +10,7 @@ import { Resource } from "@opencode/console-resource"
import { Billing } from "../../../../core/src/billing"
import { Actor } from "@opencode/console-core/actor.js"
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
import { ZenModel } from "@opencode/console-core/model.js"
export async function handler(
input: APIEvent,
@ -34,32 +35,7 @@ export async function handler(
class MonthlyLimitError extends Error {}
class ModelError extends Error {}
const ModelCostSchema = z.object({
input: z.number(),
output: z.number(),
cacheRead: z.number().optional(),
cacheWrite5m: z.number().optional(),
cacheWrite1h: z.number().optional(),
})
const ModelSchema = z.object({
cost: ModelCostSchema,
cost200K: ModelCostSchema.optional(),
allowAnonymous: z.boolean().optional(),
providers: z.array(
z.object({
id: z.string(),
api: z.string(),
apiKey: z.string(),
model: z.string(),
weight: z.number().optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
disabled: z.boolean().optional(),
}),
),
})
type Model = z.infer<typeof ModelSchema>
type Model = z.infer<typeof ZenModel.ModelSchema>
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
@ -230,7 +206,7 @@ export async function handler(
function validateModel(reqModel: string) {
const json = JSON.parse(Resource.ZEN_MODELS.value)
const allModels = z.record(z.string(), ModelSchema).parse(json)
const allModels = ZenModel.ModelsSchema.parse(json)
if (!(reqModel in allModels)) {
throw new ModelError(`Model ${reqModel} not supported`)

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "0.11.6",
"version": "0.12.1",
"private": true,
"type": "module",
"dependencies": {
@ -20,6 +20,9 @@
"db": "sst shell drizzle-kit",
"db-dev": "sst shell --stage dev -- drizzle-kit",
"db-prod": "sst shell --stage production -- drizzle-kit",
"update-models": "script/update-models.ts",
"promote-models-to-dev": "script/promote-models.ts dev",
"promote-models-to-prod": "script/promote-models.ts production",
"typecheck": "tsc --noEmit"
},
"devDependencies": {

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import { ZenModel } from "../src/model"
const stage = process.argv[2]
if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const value = ret
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS"))
?.split("=")[1]
if (!value) throw new Error("ZEN_MODELS not found")
// validate value
ZenModel.ModelsSchema.parse(JSON.parse(value))
// update the secret
await $`bun sst secret set ZEN_MODELS ${value} --stage ${stage}`

View file

@ -0,0 +1,32 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "path"
import os from "os"
import { ZenModel } from "../src/model"
const root = path.resolve(process.cwd(), "..", "..", "..")
const models = await $`bun sst secret list`.cwd(root).text()
console.log("models", models)
// read the line starting with "ZEN_MODELS"
const oldValue = models
.split("\n")
.find((line) => line.startsWith("ZEN_MODELS"))
?.split("=")[1]
if (!oldValue) throw new Error("ZEN_MODELS not found")
console.log("oldValue", oldValue)
// store the prettified json to a temp file
const filename = `models-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
await $`vim ${tempFile.name}`
const newValue = JSON.parse(await tempFile.text())
ZenModel.ModelsSchema.parse(newValue)
// update the secret
await $`bun sst secret set ZEN_MODELS ${JSON.stringify(newValue)}`

View file

@ -206,9 +206,6 @@ export namespace Billing {
customer_email: user.email,
customer_creation: "always",
}),
metadata: {
workspaceID: Actor.workspace(),
},
currency: "usd",
invoice_creation: {
enabled: true,
@ -220,6 +217,16 @@ export namespace Billing {
payment_method_data: {
allow_redisplay: "always",
},
customer_update: {
name: "auto",
address: "auto",
},
tax_id_collection: {
enabled: true,
},
metadata: {
workspaceID: Actor.workspace(),
},
success_url: successUrl,
cancel_url: cancelUrl,
})

View file

@ -0,0 +1,30 @@
import { z } from "zod"
export namespace ZenModel {
const ModelCostSchema = z.object({
input: z.number(),
output: z.number(),
cacheRead: z.number().optional(),
cacheWrite5m: z.number().optional(),
cacheWrite1h: z.number().optional(),
})
export const ModelSchema = z.object({
cost: ModelCostSchema,
cost200K: ModelCostSchema.optional(),
allowAnonymous: z.boolean().optional(),
providers: z.array(
z.object({
id: z.string(),
api: z.string(),
apiKey: z.string(),
model: z.string(),
weight: z.number().optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
disabled: z.boolean().optional(),
}),
),
})
export const ModelsSchema = z.record(z.string(), ModelSchema)
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "0.11.6",
"version": "0.12.1",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode/console-scripts",
"version": "0.11.6",
"version": "0.12.1",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.11.6",
"version": "0.12.1",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,2 +1,7 @@
<<<<<<< HEAD
preload = ["@opentui/solid/preload"]
=======
[test]
preload = ["./test/preload.ts"]
>>>>>>> dev

View file

@ -1,13 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.11.6",
"version": "0.12.1",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit",
"dev": "bun run --conditions=development --conditions=browser ./src/index.ts",
"build": "./script/build.ts"
"test": "bun test",
"build": "./script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts"
},
"bin": {
"opencode": "./bin/opencode"

View file

@ -186,6 +186,14 @@ export const RunCommand = cmd({
}
}
if (part.type === "step-start") {
if (outputJsonEvent("step_start", { part })) return
}
if (part.type === "step-finish") {
if (outputJsonEvent("step_finish", { part })) return
}
if (part.type === "text") {
text = part.text

View file

@ -60,7 +60,7 @@ export namespace Config {
]
for (const dir of directories) {
await assertValid(dir)
await assertValid(dir).catch(() => {})
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir))
@ -118,9 +118,15 @@ export namespace Config {
})
async function assertValid(dir: string) {
const ALLOWED_DIRS = new Set(["agent", "command", "mode", "plugin", "tool"])
const ALLOWED_DIRS = new Set(["agent", "command", "mode", "plugin", "tool", "themes"])
const UNEXPECTED_DIR_GLOB = new Bun.Glob("*/")
for await (const item of UNEXPECTED_DIR_GLOB.scan({ absolute: true, cwd: dir, onlyFiles: false })) {
for await (const item of UNEXPECTED_DIR_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
cwd: dir,
onlyFiles: false,
})) {
const dirname = path.basename(item)
if (!ALLOWED_DIRS.has(dirname)) {
throw new InvalidError({
@ -134,7 +140,7 @@ export namespace Config {
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
for await (const item of COMMAND_GLOB.scan({ absolute: true, cwd: dir })) {
for await (const item of COMMAND_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
@ -169,7 +175,7 @@ export namespace Config {
async function loadAgent(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of AGENT_GLOB.scan({ absolute: true, cwd: dir })) {
for await (const item of AGENT_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
@ -207,7 +213,7 @@ export namespace Config {
const MODE_GLOB = new Bun.Glob("mode/*.md")
async function loadMode(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of MODE_GLOB.scan({ absolute: true, cwd: dir })) {
for await (const item of MODE_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
const content = await Bun.file(item).text()
const md = matter(content)
if (!md.data) continue
@ -233,7 +239,7 @@ export namespace Config {
async function loadPlugin(dir: string) {
const plugins: string[] = []
for await (const item of PLUGIN_GLOB.scan({ absolute: true, cwd: dir })) {
for await (const item of PLUGIN_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) {
plugins.push("file://" + item)
}
return plugins

View file

@ -0,0 +1,12 @@
export namespace ConfigMarkdown {
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export const SHELL_REGEX = /`[^`]+`/g
export function files(template: string) {
return Array.from(template.matchAll(FILE_REGEX))
}
export function shell(template: string) {
return Array.from(template.matchAll(SHELL_REGEX))
}
}

View file

@ -48,6 +48,7 @@ import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $ } from "bun"
import { ConfigMarkdown } from "../config/markdown"
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
@ -1354,7 +1355,6 @@ export namespace SessionPrompt {
* Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
* Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
*/
export const fileRegex = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export async function command(input: CommandInput) {
log.info("command", input)
@ -1363,10 +1363,10 @@ export namespace SessionPrompt {
let template = command.template.replace("$ARGUMENTS", input.arguments)
const bash = Array.from(template.matchAll(bashRegex))
if (bash.length > 0) {
const shell = ConfigMarkdown.shell(template)
if (shell.length > 0) {
const results = await Promise.all(
bash.map(async ([, cmd]) => {
shell.map(async ([, cmd]) => {
try {
return await $`${{ raw: cmd }}`.nothrow().text()
} catch (error) {
@ -1385,9 +1385,9 @@ export namespace SessionPrompt {
},
] as PromptInput["parts"]
const matches = Array.from(template.matchAll(fileRegex))
const files = ConfigMarkdown.files(template)
await Promise.all(
matches.map(async (match) => {
files.map(async (match) => {
const name = match[1]
const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2))

View file

@ -596,13 +596,13 @@ export function replace(content: string, oldString: string, newString: string, r
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
// BlockAnchorReplacer,
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
// TrimmedBoundaryReplacer,
// ContextAwareReplacer,
// MultiOccurrenceReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)

View file

@ -20,29 +20,12 @@ import z from "zod/v4"
import { Plugin } from "../plugin"
export namespace ToolRegistry {
// Built-in tools that ship with opencode
const BUILTIN = [
InvalidTool,
BashTool,
EditTool,
WebFetchTool,
GlobTool,
GrepTool,
ListTool,
PatchTool,
ReadTool,
WriteTool,
TodoWriteTool,
TodoReadTool,
TaskTool,
]
export const state = Instance.state(async () => {
const custom = [] as Tool.Info[]
const glob = new Bun.Glob("tool/*.{js,ts}")
for (const dir of await Config.directories()) {
for await (const match of glob.scan({ cwd: dir, absolute: true })) {
for await (const match of glob.scan({ cwd: dir, absolute: true, followSymlinks: true, dot: true })) {
const namespace = path.basename(match, path.extname(match))
const mod = await import(match)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
@ -91,7 +74,22 @@ export namespace ToolRegistry {
async function all(): Promise<Tool.Info[]> {
const custom = await state().then((x) => x.custom)
return [...BUILTIN, ...custom]
return [
InvalidTool,
BashTool,
EditTool,
WebFetchTool,
GlobTool,
GrepTool,
ListTool,
PatchTool,
ReadTool,
WriteTool,
TodoWriteTool,
TodoReadTool,
TaskTool,
...custom,
]
}
export async function ids() {

View file

@ -0,0 +1,352 @@
import { test, expect } from "bun:test"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.username).toBeDefined()
},
})
})
test("loads JSON config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: "test/model",
username: "testuser",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
})
})
test("loads JSONC config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
// This is a comment
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"username": "testuser"
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
})
})
test("merges multiple config files with correct precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: "base",
username: "base",
}),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: "override",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("override")
expect(config.username).toBe("base")
},
})
})
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test_theme"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
theme: "{env:TEST_VAR}",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_theme")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["TEST_VAR"] = originalEnv
} else {
delete process.env["TEST_VAR"]
}
}
})
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "included.txt"), "test_theme")
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
theme: "{file:included.txt}",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_theme")
},
})
})
test("validates config schema and throws on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
invalid_field: "should cause error",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Strict schema should throw an error for invalid fields
await expect(Config.get()).rejects.toThrow()
},
})
})
test("throws error for invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(Config.get()).rejects.toThrow()
},
})
})
test("handles agent configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "test/model",
temperature: 0.7,
description: "test agent",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test_agent"]).toEqual({
model: "test/model",
temperature: 0.7,
description: "test agent",
})
},
})
})
test("handles command configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
command: {
test_command: {
template: "test template",
description: "test command",
agent: "test_agent",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.command?.["test_command"]).toEqual({
template: "test template",
description: "test command",
agent: "test_agent",
})
},
})
})
test("migrates autoshare to share field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
autoshare: true,
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true)
},
})
})
test("migrates mode field to agent field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mode: {
test_mode: {
model: "test/model",
temperature: 0.5,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test_mode"]).toEqual({
model: "test/model",
temperature: 0.5,
mode: "primary",
})
},
})
})
test("loads config from .opencode directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentDir = path.join(opencodeDir, "agent")
await fs.mkdir(agentDir, { recursive: true })
await Bun.write(
path.join(agentDir, "test.md"),
`---
model: test/model
---
Test agent prompt`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]).toEqual({
name: "test",
model: "test/model",
prompt: "Test agent prompt",
})
},
})
})
test("updates config and writes to file", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const newConfig = { model: "updated/model" }
await Config.update(newConfig as any)
const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
expect(writtenConfig.model).toBe("updated/model")
},
})
})
test("gets config directories", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await Config.directories()
expect(dirs.length).toBeGreaterThanOrEqual(1)
},
})
})

View file

@ -0,0 +1,89 @@
import { expect, test } from "bun:test"
import { ConfigMarkdown } from "../../src/config/markdown"
const template = `This is a @valid/path/to/a/file and it should also match at
the beginning of a line:
@another-valid/path/to/a/file
but this is not:
- Adds a "Co-authored-by:" footer which clarifies which AI agent
helped create this commit, using an appropriate \`noreply@...\`
or \`noreply@anthropic.com\` email address.
We also need to deal with files followed by @commas, ones
with @file-extensions.md, even @multiple.extensions.bak,
hidden directorys like @.config/ or files like @.bashrc
and ones at the end of a sentence like @foo.md.
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
as well as @~/home-files and @~/paths/under/home.txt.
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
const matches = ConfigMarkdown.files(template)
test("should extract exactly 12 file references", () => {
expect(matches.length).toBe(12)
})
test("should extract valid/path/to/a/file", () => {
expect(matches[0][1]).toBe("valid/path/to/a/file")
})
test("should extract another-valid/path/to/a/file", () => {
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
})
test("should extract paths ignoring comma after", () => {
expect(matches[2][1]).toBe("commas")
})
test("should extract a path with a file extension and comma after", () => {
expect(matches[3][1]).toBe("file-extensions.md")
})
test("should extract a path with multiple dots and comma after", () => {
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = ConfigMarkdown.files(backtickTest)
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = ConfigMarkdown.files(emailTest)
expect(emailMatches.length).toBe(0)
})

View file

@ -0,0 +1,24 @@
import { $ } from "bun"
import os from "os"
import path from "path"
type TmpDirOptions<T> = {
git?: boolean
init?: (dir: string) => Promise<T>
dispose?: (dir: string) => Promise<T>
}
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
await $`mkdir -p ${dirpath}`.quiet()
if (options?.git) await $`git init`.cwd(dirpath).quiet()
const extra = await options?.init?.(dirpath)
const result = {
[Symbol.asyncDispose]: async () => {
await options?.dispose?.(dirpath)
await $`rm -rf ${dirpath}`.quiet()
},
path: dirpath,
extra: extra as T,
}
return result
}

View file

@ -1 +0,0 @@
// Test fixture for ListTool

View file

@ -1 +0,0 @@
// Test fixture for ListTool

View file

@ -1 +0,0 @@
// Test fixture for ListTool

View file

@ -0,0 +1,7 @@
import { Log } from "../src/util/log"
Log.init({
print: false,
dev: true,
level: "DEBUG",
})

View file

@ -1,91 +0,0 @@
import { describe, expect, test } from "bun:test"
import { SessionPrompt } from "../../src/session/prompt"
describe("fileRegex", () => {
const template = `This is a @valid/path/to/a/file and it should also match at
the beginning of a line:
@another-valid/path/to/a/file
but this is not:
- Adds a "Co-authored-by:" footer which clarifies which AI agent
helped create this commit, using an appropriate \`noreply@...\`
or \`noreply@anthropic.com\` email address.
We also need to deal with files followed by @commas, ones
with @file-extensions.md, even @multiple.extensions.bak,
hidden directorys like @.config/ or files like @.bashrc
and ones at the end of a sentence like @foo.md.
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
as well as @~/home-files and @~/paths/under/home.txt.
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
const matches = Array.from(template.matchAll(SessionPrompt.fileRegex))
test("should extract exactly 12 file references", () => {
expect(matches.length).toBe(12)
})
test("should extract valid/path/to/a/file", () => {
expect(matches[0][1]).toBe("valid/path/to/a/file")
})
test("should extract another-valid/path/to/a/file", () => {
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
})
test("should extract paths ignoring comma after", () => {
expect(matches[2][1]).toBe("commas")
})
test("should extract a path with a file extension and comma after", () => {
expect(matches[3][1]).toBe("file-extensions.md")
})
test("should extract a path with multiple dots and comma after", () => {
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = Array.from(backtickTest.matchAll(SessionPrompt.fileRegex))
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = Array.from(emailTest.matchAll(SessionPrompt.fileRegex))
expect(emailMatches.length).toBe(0)
})
})

View file

@ -2,40 +2,38 @@ import { test, expect } from "bun:test"
import { $ } from "bun"
import { Snapshot } from "../../src/snapshot"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
async function bootstrap() {
const dir = await $`mktemp -d`.text().then((t) => t.trim())
// Randomize file contents to ensure unique git repos
const unique = Math.random().toString(36).slice(2)
const aContent = `A${unique}`
const bContent = `B${unique}`
await Bun.write(`${dir}/a.txt`, aContent)
await Bun.write(`${dir}/b.txt`, bContent)
await $`git init`.cwd(dir).quiet()
await $`git add .`.cwd(dir).quiet()
await $`git commit -m init`.cwd(dir).quiet()
return {
[Symbol.asyncDispose]: async () => {
await $`rm -rf ${dir}`.quiet()
return tmpdir({
git: true,
init: async (dir) => {
const unique = Math.random().toString(36).slice(2)
const aContent = `A${unique}`
const bContent = `B${unique}`
await Bun.write(`${dir}/a.txt`, aContent)
await Bun.write(`${dir}/b.txt`, bContent)
await $`git add .`.cwd(dir).quiet()
await $`git commit -m init`.cwd(dir).quiet()
return {
aContent,
bContent,
}
},
dir,
aContent,
bContent,
}
})
}
test("tracks deleted files correctly", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`rm ${tmp.dir}/a.txt`.quiet()
await $`rm ${tmp.path}/a.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/a.txt`)
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/a.txt`)
},
})
})
@ -43,16 +41,16 @@ test("tracks deleted files correctly", async () => {
test("revert should remove new files", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/new.txt`, "NEW")
await Bun.write(`${tmp.path}/new.txt`, "NEW")
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(false)
expect(await Bun.file(`${tmp.path}/new.txt`).exists()).toBe(false)
},
})
})
@ -60,17 +58,17 @@ test("revert should remove new files", async () => {
test("revert in subdirectory", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/sub`.quiet()
await Bun.write(`${tmp.dir}/sub/file.txt`, "SUB")
await $`mkdir -p ${tmp.path}/sub`.quiet()
await Bun.write(`${tmp.path}/sub/file.txt`, "SUB")
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/sub/file.txt`).exists()).toBe(false)
expect(await Bun.file(`${tmp.path}/sub/file.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories
// The empty subdirectory will remain
},
@ -80,24 +78,24 @@ test("revert in subdirectory", async () => {
test("multiple file operations", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/c.txt`, "C")
await $`mkdir -p ${tmp.dir}/dir`.quiet()
await Bun.write(`${tmp.dir}/dir/d.txt`, "D")
await Bun.write(`${tmp.dir}/b.txt`, "MODIFIED")
await $`rm ${tmp.path}/a.txt`.quiet()
await Bun.write(`${tmp.path}/c.txt`, "C")
await $`mkdir -p ${tmp.path}/dir`.quiet()
await Bun.write(`${tmp.path}/dir/d.txt`, "D")
await Bun.write(`${tmp.path}/b.txt`, "MODIFIED")
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/c.txt`).exists()).toBe(false)
expect(await Bun.file(`${tmp.path}/a.txt`).text()).toBe(tmp.extra.aContent)
expect(await Bun.file(`${tmp.path}/c.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories
// The empty directory will remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
expect(await Bun.file(`${tmp.path}/b.txt`).text()).toBe(tmp.extra.bContent)
},
})
})
@ -105,12 +103,12 @@ test("multiple file operations", async () => {
test("empty directory handling", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir ${tmp.dir}/empty`.quiet()
await $`mkdir ${tmp.path}/empty`.quiet()
expect((await Snapshot.patch(before!)).files.length).toBe(0)
},
@ -120,18 +118,18 @@ test("empty directory handling", async () => {
test("binary file handling", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/image.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
await Bun.write(`${tmp.path}/image.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/image.png`)
expect(patch.files).toContain(`${tmp.path}/image.png`)
await Snapshot.revert([patch])
expect(await Bun.file(`${tmp.dir}/image.png`).exists()).toBe(false)
expect(await Bun.file(`${tmp.path}/image.png`).exists()).toBe(false)
},
})
})
@ -139,14 +137,14 @@ test("binary file handling", async () => {
test("symlink handling", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`ln -s ${tmp.dir}/a.txt ${tmp.dir}/link.txt`.quiet()
await $`ln -s ${tmp.path}/a.txt ${tmp.path}/link.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/link.txt`)
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/link.txt`)
},
})
})
@ -154,14 +152,14 @@ test("symlink handling", async () => {
test("large file handling", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/large.txt`, "x".repeat(1024 * 1024))
await Bun.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/large.txt`)
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.path}/large.txt`)
},
})
})
@ -169,17 +167,17 @@ test("large file handling", async () => {
test("nested directory revert", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/level1/level2/level3`.quiet()
await Bun.write(`${tmp.dir}/level1/level2/level3/deep.txt`, "DEEP")
await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet()
await Bun.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/level1/level2/level3/deep.txt`).exists()).toBe(false)
expect(await Bun.file(`${tmp.path}/level1/level2/level3/deep.txt`).exists()).toBe(false)
},
})
})
@ -187,19 +185,19 @@ test("nested directory revert", async () => {
test("special characters in filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/file with spaces.txt`, "SPACES")
await Bun.write(`${tmp.dir}/file-with-dashes.txt`, "DASHES")
await Bun.write(`${tmp.dir}/file_with_underscores.txt`, "UNDERSCORES")
await Bun.write(`${tmp.path}/file with spaces.txt`, "SPACES")
await Bun.write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
await Bun.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
const files = (await Snapshot.patch(before!)).files
expect(files).toContain(`${tmp.dir}/file with spaces.txt`)
expect(files).toContain(`${tmp.dir}/file-with-dashes.txt`)
expect(files).toContain(`${tmp.dir}/file_with_underscores.txt`)
expect(files).toContain(`${tmp.path}/file with spaces.txt`)
expect(files).toContain(`${tmp.path}/file-with-dashes.txt`)
expect(files).toContain(`${tmp.path}/file_with_underscores.txt`)
},
})
})
@ -207,7 +205,7 @@ test("special characters in filenames", async () => {
test("revert with empty patches", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
// Should not crash with empty patches
expect(Snapshot.revert([])).resolves.toBeUndefined()
@ -221,13 +219,13 @@ test("revert with empty patches", async () => {
test("patch with invalid hash", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Create a change
await Bun.write(`${tmp.dir}/test.txt`, "TEST")
await Bun.write(`${tmp.path}/test.txt`, "TEST")
// Try to patch with invalid hash - should handle gracefully
const patch = await Snapshot.patch("invalid-hash-12345")
@ -240,7 +238,7 @@ test("patch with invalid hash", async () => {
test("revert non-existent file", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
@ -251,7 +249,7 @@ test("revert non-existent file", async () => {
Snapshot.revert([
{
hash: before!,
files: [`${tmp.dir}/nonexistent.txt`],
files: [`${tmp.path}/nonexistent.txt`],
},
]),
).resolves.toBeUndefined()
@ -262,16 +260,16 @@ test("revert non-existent file", async () => {
test("unicode filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const unicodeFiles = [
`${tmp.dir}/文件.txt`,
`${tmp.dir}/🚀rocket.txt`,
`${tmp.dir}/café.txt`,
`${tmp.dir}/файл.txt`,
`${tmp.path}/文件.txt`,
`${tmp.path}/🚀rocket.txt`,
`${tmp.path}/café.txt`,
`${tmp.path}/файл.txt`,
]
for (const file of unicodeFiles) {
@ -292,13 +290,13 @@ test("unicode filenames", async () => {
test("very long filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const longName = "a".repeat(200) + ".txt"
const longFile = `${tmp.dir}/${longName}`
const longFile = `${tmp.path}/${longName}`
await Bun.write(longFile, "long filename content")
@ -314,19 +312,19 @@ test("very long filenames", async () => {
test("hidden files", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/.hidden`, "hidden content")
await Bun.write(`${tmp.dir}/.gitignore`, "*.log")
await Bun.write(`${tmp.dir}/.config`, "config content")
await Bun.write(`${tmp.path}/.hidden`, "hidden content")
await Bun.write(`${tmp.path}/.gitignore`, "*.log")
await Bun.write(`${tmp.path}/.config`, "config content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/.hidden`)
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
expect(patch.files).toContain(`${tmp.dir}/.config`)
expect(patch.files).toContain(`${tmp.path}/.hidden`)
expect(patch.files).toContain(`${tmp.path}/.gitignore`)
expect(patch.files).toContain(`${tmp.path}/.config`)
},
})
})
@ -334,19 +332,19 @@ test("hidden files", async () => {
test("nested symlinks", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/sub/dir`.quiet()
await Bun.write(`${tmp.dir}/sub/dir/target.txt`, "target content")
await $`ln -s ${tmp.dir}/sub/dir/target.txt ${tmp.dir}/sub/dir/link.txt`.quiet()
await $`ln -s ${tmp.dir}/sub ${tmp.dir}/sub-link`.quiet()
await $`mkdir -p ${tmp.path}/sub/dir`.quiet()
await Bun.write(`${tmp.path}/sub/dir/target.txt`, "target content")
await $`ln -s ${tmp.path}/sub/dir/target.txt ${tmp.path}/sub/dir/link.txt`.quiet()
await $`ln -s ${tmp.path}/sub ${tmp.path}/sub-link`.quiet()
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/sub/dir/link.txt`)
expect(patch.files).toContain(`${tmp.dir}/sub-link`)
expect(patch.files).toContain(`${tmp.path}/sub/dir/link.txt`)
expect(patch.files).toContain(`${tmp.path}/sub-link`)
},
})
})
@ -354,15 +352,15 @@ test("nested symlinks", async () => {
test("file permissions and ownership changes", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Change permissions multiple times
await $`chmod 600 ${tmp.dir}/a.txt`.quiet()
await $`chmod 755 ${tmp.dir}/a.txt`.quiet()
await $`chmod 644 ${tmp.dir}/a.txt`.quiet()
await $`chmod 600 ${tmp.path}/a.txt`.quiet()
await $`chmod 755 ${tmp.path}/a.txt`.quiet()
await $`chmod 644 ${tmp.path}/a.txt`.quiet()
const patch = await Snapshot.patch(before!)
// Note: git doesn't track permission changes on existing files by default
@ -375,13 +373,13 @@ test("file permissions and ownership changes", async () => {
test("circular symlinks", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Create circular symlink
await $`ln -s ${tmp.dir}/circular ${tmp.dir}/circular`.quiet().nothrow()
await $`ln -s ${tmp.path}/circular ${tmp.path}/circular`.quiet().nothrow()
const patch = await Snapshot.patch(before!)
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
@ -392,23 +390,23 @@ test("circular symlinks", async () => {
test("gitignore changes", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/.gitignore`, "*.ignored")
await Bun.write(`${tmp.dir}/test.ignored`, "ignored content")
await Bun.write(`${tmp.dir}/normal.txt`, "normal content")
await Bun.write(`${tmp.path}/.gitignore`, "*.ignored")
await Bun.write(`${tmp.path}/test.ignored`, "ignored content")
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!)
// Should track gitignore itself
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
expect(patch.files).toContain(`${tmp.path}/.gitignore`)
// Should track normal files
expect(patch.files).toContain(`${tmp.dir}/normal.txt`)
expect(patch.files).toContain(`${tmp.path}/normal.txt`)
// Should not track ignored files (git won't see them)
expect(patch.files).not.toContain(`${tmp.dir}/test.ignored`)
expect(patch.files).not.toContain(`${tmp.path}/test.ignored`)
},
})
})
@ -416,7 +414,7 @@ test("gitignore changes", async () => {
test("concurrent file operations during patch", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
@ -424,7 +422,7 @@ test("concurrent file operations during patch", async () => {
// Start creating files
const createPromise = (async () => {
for (let i = 0; i < 10; i++) {
await Bun.write(`${tmp.dir}/concurrent${i}.txt`, `concurrent${i}`)
await Bun.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`)
// Small delay to simulate concurrent operations
await new Promise((resolve) => setTimeout(resolve, 1))
}
@ -448,25 +446,25 @@ test("snapshot state isolation between projects", async () => {
await using tmp2 = await bootstrap()
await Instance.provide({
directory: tmp1.dir,
directory: tmp1.path,
fn: async () => {
const before1 = await Snapshot.track()
await Bun.write(`${tmp1.dir}/project1.txt`, "project1 content")
await Bun.write(`${tmp1.path}/project1.txt`, "project1 content")
const patch1 = await Snapshot.patch(before1!)
expect(patch1.files).toContain(`${tmp1.dir}/project1.txt`)
expect(patch1.files).toContain(`${tmp1.path}/project1.txt`)
},
})
await Instance.provide({
directory: tmp2.dir,
directory: tmp2.path,
fn: async () => {
const before2 = await Snapshot.track()
await Bun.write(`${tmp2.dir}/project2.txt`, "project2 content")
await Bun.write(`${tmp2.path}/project2.txt`, "project2 content")
const patch2 = await Snapshot.patch(before2!)
expect(patch2.files).toContain(`${tmp2.dir}/project2.txt`)
expect(patch2.files).toContain(`${tmp2.path}/project2.txt`)
// Ensure project1 files don't appear in project2
expect(patch2.files).not.toContain(`${tmp1?.dir}/project1.txt`)
expect(patch2.files).not.toContain(`${tmp1?.path}/project1.txt`)
},
})
})
@ -474,7 +472,7 @@ test("snapshot state isolation between projects", async () => {
test("track with no changes returns same hash", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const hash1 = await Snapshot.track()
expect(hash1).toBeTruthy()
@ -493,15 +491,15 @@ test("track with no changes returns same hash", async () => {
test("diff function with various changes", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Make various changes
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/new.txt`, "new content")
await Bun.write(`${tmp.dir}/b.txt`, "modified content")
await $`rm ${tmp.path}/a.txt`.quiet()
await Bun.write(`${tmp.path}/new.txt`, "new content")
await Bun.write(`${tmp.path}/b.txt`, "modified content")
const diff = await Snapshot.diff(before!)
expect(diff).toContain("deleted")
@ -514,23 +512,23 @@ test("diff function with various changes", async () => {
test("restore function", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.dir,
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Make changes
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/new.txt`, "new content")
await Bun.write(`${tmp.dir}/b.txt`, "modified")
await $`rm ${tmp.path}/a.txt`.quiet()
await Bun.write(`${tmp.path}/new.txt`, "new content")
await Bun.write(`${tmp.path}/b.txt`, "modified")
// Restore to original state
await Snapshot.restore(before!)
expect(await Bun.file(`${tmp.dir}/a.txt`).exists()).toBe(true)
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(true) // New files should remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
expect(await Bun.file(`${tmp.path}/a.txt`).exists()).toBe(true)
expect(await Bun.file(`${tmp.path}/a.txt`).text()).toBe(tmp.extra.aContent)
expect(await Bun.file(`${tmp.path}/new.txt`).exists()).toBe(true) // New files should remain
expect(await Bun.file(`${tmp.path}/b.txt`).text()).toBe(tmp.extra.bContent)
},
})
})

View file

@ -1,424 +0,0 @@
import { describe, expect, test } from "bun:test"
import { replace } from "../../src/tool/edit"
interface TestCase {
content: string
find: string
replace: string
all?: boolean
fail?: boolean
}
const testCases: TestCase[] = [
// SimpleReplacer cases
{
content: ["function hello() {", ' console.log("world");', "}"].join("\n"),
find: 'console.log("world");',
replace: 'console.log("universe");',
},
{
content: ["if (condition) {", " doSomething();", " doSomethingElse();", "}"].join("\n"),
find: [" doSomething();", " doSomethingElse();"].join("\n"),
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
},
// LineTrimmedReplacer cases
{
content: ["function test() {", ' console.log("hello");', "}"].join("\n"),
find: 'console.log("hello");',
replace: 'console.log("goodbye");',
},
{
content: ["const x = 5; ", "const y = 10;"].join("\n"),
find: "const x = 5;",
replace: "const x = 15;",
},
{
content: [" if (true) {", " return false;", " }"].join("\n"),
find: ["if (true) {", "return false;", "}"].join("\n"),
replace: ["if (false) {", "return true;", "}"].join("\n"),
},
// BlockAnchorReplacer cases
{
content: [
"function calculate(a, b) {",
" const temp = a + b;",
" const result = temp * 2;",
" return result;",
"}",
].join("\n"),
find: ["function calculate(a, b) {", " // different middle content", " return result;", "}"].join("\n"),
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join("\n"),
},
{
content: [
"class MyClass {",
" constructor() {",
" this.value = 0;",
" }",
" ",
" getValue() {",
" return this.value;",
" }",
"}",
].join("\n"),
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
replace: ["class MyClass {", " constructor() {", " this.value = 42;", " }", "}"].join("\n"),
},
// WhitespaceNormalizedReplacer cases
{
content: ["function test() {", '\tconsole.log("hello");', "}"].join("\n"),
find: ' console.log("hello");',
replace: ' console.log("world");',
},
{
content: "const x = 5;",
find: "const x = 5;",
replace: "const x = 10;",
},
{
content: "if\t( condition\t) {",
find: "if ( condition ) {",
replace: "if (newCondition) {",
},
// IndentationFlexibleReplacer cases
{
content: [" function nested() {", ' console.log("deeply nested");', " return true;", " }"].join(
"\n",
),
find: ["function nested() {", ' console.log("deeply nested");', " return true;", "}"].join("\n"),
replace: ["function nested() {", ' console.log("updated");', " return false;", "}"].join("\n"),
},
{
content: [" if (true) {", ' console.log("level 1");', ' console.log("level 2");', " }"].join("\n"),
find: ["if (true) {", 'console.log("level 1");', ' console.log("level 2");', "}"].join("\n"),
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
},
// replaceAll option cases
{
content: ['console.log("test");', 'console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: true,
},
{
content: ['console.log("test");', 'console.log("test");'].join("\n"),
find: 'console.log("test");',
replace: 'console.log("updated");',
all: false,
},
// Error cases
{
content: 'console.log("hello");',
find: "nonexistent string",
replace: "updated",
fail: true,
},
{
content: ["test", "test", "different content", "test"].join("\n"),
find: "test",
replace: "updated",
all: false,
fail: true,
},
// Edge cases
{
content: "",
find: "",
replace: "new content",
},
{
content: "const regex = /[.*+?^${}()|[\\\\]\\\\\\\\]/g;",
find: "/[.*+?^${}()|[\\\\]\\\\\\\\]/g",
replace: "/\\\\w+/g",
},
{
content: 'const message = "Hello 世界! 🌍";',
find: "Hello 世界! 🌍",
replace: "Hello World! 🌎",
},
// EscapeNormalizedReplacer cases
{
content: 'console.log("Hello\nWorld");',
find: 'console.log("Hello\\nWorld");',
replace: 'console.log("Hello\nUniverse");',
},
{
content: "const str = 'It's working';",
find: "const str = 'It\\'s working';",
replace: "const str = 'It's fixed';",
},
{
content: "const template = `Hello ${name}`;",
find: "const template = `Hello \\${name}`;",
replace: "const template = `Hi ${name}`;",
},
{
content: "const path = 'C:\\Users\\test';",
find: "const path = 'C:\\\\Users\\\\test';",
replace: "const path = 'C:\\Users\\admin';",
},
// MultiOccurrenceReplacer cases (with replaceAll)
{
content: ["debug('start');", "debug('middle');", "debug('end');"].join("\n"),
find: "debug",
replace: "log",
all: true,
},
{
content: "const x = 1; const y = 1; const z = 1;",
find: "1",
replace: "2",
all: true,
},
// TrimmedBoundaryReplacer cases
{
content: [" function test() {", " return true;", " }"].join("\n"),
find: ["function test() {", " return true;", "}"].join("\n"),
replace: ["function test() {", " return false;", "}"].join("\n"),
},
{
content: "\n const value = 42; \n",
find: "const value = 42;",
replace: "const value = 24;",
},
{
content: ["", " if (condition) {", " doSomething();", " }", ""].join("\n"),
find: ["if (condition) {", " doSomething();", "}"].join("\n"),
replace: ["if (condition) {", " doNothing();", "}"].join("\n"),
},
// ContextAwareReplacer cases
{
content: [
"function calculate(a, b) {",
" const temp = a + b;",
" const result = temp * 2;",
" return result;",
"}",
].join("\n"),
find: [
"function calculate(a, b) {",
" // some different content here",
" // more different content",
" return result;",
"}",
].join("\n"),
replace: ["function calculate(a, b) {", " return (a + b) * 2;", "}"].join("\n"),
},
{
content: [
"class TestClass {",
" constructor() {",
" this.value = 0;",
" }",
" ",
" method() {",
" return this.value;",
" }",
"}",
].join("\n"),
find: ["class TestClass {", " // different implementation", " // with multiple lines", "}"].join("\n"),
replace: ["class TestClass {", " getValue() { return 42; }", "}"].join("\n"),
},
// Combined edge cases for new replacers
{
content: '\tconsole.log("test");\t',
find: 'console.log("test");',
replace: 'console.log("updated");',
},
{
content: [" ", "function test() {", " return 'value';", "}", " "].join("\n"),
find: ["function test() {", "return 'value';", "}"].join("\n"),
replace: ["function test() {", "return 'new value';", "}"].join("\n"),
},
// Test for same oldString and newString (should fail)
{
content: 'console.log("test");',
find: 'console.log("test");',
replace: 'console.log("test");',
fail: true,
},
// Additional tests for fixes made
// WhitespaceNormalizedReplacer - test regex special characters that could cause errors
{
content: 'const pattern = "test[123]";',
find: "test[123]",
replace: "test[456]",
},
{
content: 'const regex = "^start.*end$";',
find: "^start.*end$",
replace: "^begin.*finish$",
},
// EscapeNormalizedReplacer - test single backslash vs double backslash
{
content: 'const path = "C:\\Users";',
find: 'const path = "C:\\Users";',
replace: 'const path = "D:\\Users";',
},
{
content: 'console.log("Line1\\nLine2");',
find: 'console.log("Line1\\nLine2");',
replace: 'console.log("First\\nSecond");',
},
// BlockAnchorReplacer - test edge case with exact newline boundaries
{
content: ["function test() {", " return true;", "}"].join("\n"),
find: ["function test() {", " // middle", "}"].join("\n"),
replace: ["function test() {", " return false;", "}"].join("\n"),
},
// ContextAwareReplacer - test with trailing newline in find string
{
content: ["class Test {", " method1() {", " return 1;", " }", "}"].join("\n"),
find: [
"class Test {",
" // different content",
"}",
"", // trailing empty line
].join("\n"),
replace: ["class Test {", " method2() { return 2; }", "}"].join("\n"),
},
// Test validation for empty strings with same oldString and newString
{
content: "",
find: "",
replace: "",
fail: true,
},
// Test multiple occurrences with replaceAll=false (should fail)
{
content: ["const a = 1;", "const b = 1;", "const c = 1;"].join("\n"),
find: "= 1",
replace: "= 2",
all: false,
fail: true,
},
// Test whitespace normalization with multiple spaces and tabs mixed
{
content: "if\t \t( \tcondition\t )\t{",
find: "if ( condition ) {",
replace: "if (newCondition) {",
},
// Test escape sequences in template literals
{
content: "const msg = `Hello\\tWorld`;",
find: "const msg = `Hello\\tWorld`;",
replace: "const msg = `Hi\\tWorld`;",
},
// Test case that reproduces the greedy matching bug - now should fail due to low similarity
{
content: [
"func main() {",
" if condition {",
" doSomething()",
" }",
" processData()",
" if anotherCondition {",
" doOtherThing()",
" }",
" return mainLayout",
"}",
"",
"func helper() {",
" }",
" return mainLayout", // This should NOT be matched due to low similarity
"}",
].join("\n"),
find: [" }", " return mainLayout"].join("\n"),
replace: [" }", " // Add some code here", " return mainLayout"].join("\n"),
fail: true, // This should fail because the pattern has low similarity score
},
// Test case for the fix - more specific pattern should work
{
content: [
"function renderLayout() {",
" const header = createHeader()",
" const body = createBody()",
" return mainLayout",
"}",
].join("\n"),
find: ["function renderLayout() {", " // different content", " return mainLayout", "}"].join("\n"),
replace: [
"function renderLayout() {",
" const header = createHeader()",
" const body = createBody()",
" // Add minimap overlay",
" return mainLayout",
"}",
].join("\n"),
},
// Test that large blocks without arbitrary size limits can work
{
content: Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n"),
find: Array.from({ length: 50 }, (_, i) => `line ${i + 25}`).join("\n"),
replace: Array.from({ length: 50 }, (_, i) => `updated line ${i + 25}`).join("\n"),
},
// Test case for the fix - more specific pattern should work
{
content: [
"function renderLayout() {",
" const header = createHeader()",
" const body = createBody()",
" return mainLayout",
"}",
].join("\n"),
find: ["function renderLayout() {", " // different content", " return mainLayout", "}"].join("\n"),
replace: [
"function renderLayout() {",
" const header = createHeader()",
" const body = createBody()",
" // Add minimap overlay",
" return mainLayout",
"}",
].join("\n"),
},
// Test BlockAnchorReplacer with overly large blocks (should fail)
{
content:
Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n") +
"\nfunction test() {\n" +
Array.from({ length: 60 }, (_, i) => ` content ${i}`).join("\n") +
"\n return result\n}",
find: ["function test() {", " // different content", " return result", "}"].join("\n"),
replace: ["function test() {", " return 42", "}"].join("\n"),
},
]
describe("EditTool Replacers", () => {
test.each(testCases)("case %#", (testCase) => {
if (testCase.fail) {
expect(() => {
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
}).toThrow()
} else {
const result = replace(testCase.content, testCase.find, testCase.replace, testCase.all)
expect(result).toContain(testCase.replace)
}
})
})

View file

@ -1,70 +0,0 @@
import { describe, expect, test } from "bun:test"
import { GlobTool } from "../../src/tool/glob"
import { ListTool } from "../../src/tool/ls"
import path from "path"
import { Instance } from "../../src/project/instance"
const ctx = {
sessionID: "test",
messageID: "",
toolCallID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
}
const glob = await GlobTool.init()
const list = await ListTool.init()
const projectRoot = path.join(__dirname, "../..")
const fixturePath = path.join(__dirname, "../fixtures/example")
describe("tool.glob", () => {
test("truncate", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
let result = await glob.execute(
{
pattern: "**/*",
path: "../../node_modules",
},
ctx,
)
expect(result.metadata.truncated).toBe(true)
},
})
})
test("basic", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
let result = await glob.execute(
{
pattern: "*.json",
path: undefined,
},
ctx,
)
expect(result.metadata).toMatchObject({
truncated: false,
count: 2,
})
},
})
})
})
describe("tool.ls", () => {
test("basic", async () => {
const result = await Instance.provide({
directory: projectRoot,
fn: async () => {
return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx)
},
})
// Normalize absolute path to relative for consistent snapshots
const normalizedOutput = result.output.replace(fixturePath, "packages/opencode/test/fixtures/example")
expect(normalizedOutput).toMatchSnapshot()
})
})

View file

@ -5,7 +5,7 @@
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"customConditions": ["development", "browser"],
"customConditions": ["browser"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],

View file

@ -1,21 +1,15 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.11.6",
"version": "0.12.1",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"exports": {
".": {
"development": "./src/index.ts",
"import": "./dist/index.js"
},
"./tool": {
"development": "./src/tool.ts",
"import": "./dist/tool.js"
}
".": "./src/index.ts",
"./tool": "./src/tool.ts"
},
"files": [
"dist"

View file

@ -5,14 +5,24 @@ process.chdir(dir)
import { $ } from "bun"
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
await $`bun tsc`
const pkg = await import("../package.json")
for (const [key, value] of Object.entries(pkg.exports)) {
const file = value.replace("./src/", "./").replace(".ts", "")
// @ts-expect-error
pkg.exports[key] = {
import: file + ".js",
types: file + ".d.ts",
}
}
await Bun.write("./dist/package.json", JSON.stringify(pkg, null, 2))
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
if (snapshot) {
await $`bun publish --tag snapshot --access public`
await $`git checkout package.json`
await $`bun publish --tag snapshot --access public`.cwd("./dist")
}
if (!snapshot) {
await $`bun publish --access public`
await $`bun publish --access public`.cwd("./dist")
}

View file

@ -6,8 +6,7 @@
"module": "preserve",
"declaration": true,
"moduleResolution": "bundler",
"lib": ["es2022", "dom", "dom.iterable"],
"customConditions": ["development"]
"lib": ["es2022", "dom", "dom.iterable"]
},
"include": ["src"]
}

View file

@ -1,28 +1,16 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.11.6",
"version": "0.12.1",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc"
"build": "./script/build.ts"
},
"exports": {
".": {
"development": "./src/index.ts",
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./client": {
"development": "./src/client.ts",
"import": "./dist/client.js",
"types": "./dist/client.d.ts"
},
"./server": {
"development": "./src/server.ts",
"import": "./dist/server.js",
"types": "./dist/server.d.ts"
}
".": "./src/index.ts",
"./client": "./src/client.ts",
"./server": "./src/server.ts"
},
"files": [
"dist"

View file

@ -37,3 +37,5 @@ await createClient({
],
})
await $`bun prettier --write src/gen`
await $`rm -rf dist`
await $`bun tsc`

18
packages/sdk/js/script/publish.ts Normal file → Executable file
View file

@ -5,15 +5,23 @@ process.chdir(dir)
import { $ } from "bun"
await import("./generate")
await $`rm -rf dist`
await $`bun tsc`
await import("./build")
const pkg = await import("../package.json")
for (const [key, value] of Object.entries(pkg.exports)) {
const file = value.replace("./src/", "./").replace(".ts", "")
// @ts-expect-error
pkg.exports[key] = {
import: file + ".js",
types: file + ".d.ts",
}
}
await Bun.write("./dist/package.json", JSON.stringify(pkg, null, 2))
const snapshot = process.env["OPENCODE_SNAPSHOT"] === "true"
if (snapshot) {
await $`bun publish --tag snapshot`
await $`bun publish --tag snapshot`.cwd("./dist")
}
if (!snapshot) {
await $`bun publish`
await $`bun publish`.cwd("./dist")
}

View file

@ -6,8 +6,7 @@
"module": "nodenext",
"declaration": true,
"moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"customConditions": ["development"]
"lib": ["es2022", "dom", "dom.iterable"]
},
"include": ["src"],
"exclude": ["src/gen"]

View file

@ -7,7 +7,7 @@ console.log("=== Generating Stainless SDK ===")
console.log(process.cwd())
await $`rm -rf go`
await $`bun run --conditions=development ../../opencode/src/index.ts generate > openapi.json`
await $`bun run ../../opencode/src/index.ts generate > openapi.json`
await $`stl builds create --branch main --pull --allow-empty --+target go`
await $`rm -rf ../go`

View file

@ -991,9 +991,9 @@ func (a Model) home() (string, int, int) {
)
// Use limit of 4 for vscode, 6 for others
limit := 6
limit := 5
if util.IsVSCode() {
limit = 4
limit = 3
}
showVscode := util.IsVSCode()
@ -1043,8 +1043,10 @@ func (a Model) home() (string, int, int) {
editorX := max(0, (effectiveWidth-editorWidth)/2)
editorY := (a.height / 2) + (mainHeight / 2) - 3
editorYDelta := 3
if editorLines > 1 {
editorYDelta = 2
content := a.editor.Content()
editorHeight := lipgloss.Height(content)
@ -1073,7 +1075,7 @@ func (a Model) home() (string, int, int) {
)
}
return mainLayout, editorX + 5, editorY + 3
return mainLayout, editorX + 5, editorY + editorYDelta
}
func (a Model) chat() (string, int, int) {

View file

@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
"version": "0.11.6",
"version": "0.12.1",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View file

@ -204,6 +204,23 @@ opencode will automatically download any new updates when it starts up. You can
---
### TUI
You can configure TUI-specific settings through the `tui` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3
}
}
```
[Learn more about using the TUI here](/docs/tui).
---
### Formatters
You can configure code formatters through the `formatter` option.

View file

@ -325,3 +325,22 @@ Some editors like VS Code need to be started with the `--wait` flag.
:::
Some editors need command-line arguments to run in blocking mode. The `--wait` flag makes the editor process block until closed.
---
## Configure
You can customize TUI behavior through your opencode config file.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3
}
}
```
### Options
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (default: `2`, minimum: `1`)

View file

@ -2,7 +2,7 @@
import { $ } from "bun"
await $`bun run prettier --ignore-unknown --write $(git ls-files)`
await $`bun run prettier --ignore-unknown --write`
if (process.env["CI"] && (await $`git status --porcelain`.text())) {
await $`git config --local user.email "action@github.com"`

View file

@ -1,16 +0,0 @@
#!/bin/sh
if [ ! -d ".git" ]; then
exit 0
fi
mkdir -p .git/hooks
cat > .git/hooks/pre-push << 'EOF'
#!/bin/sh
bun prettier --write --ignore-unknown $(git diff --name-only HEAD~1 HEAD)
bun run typecheck
EOF
chmod +x .git/hooks/pre-push
echo "✅ Pre-push hook installed"

View file

@ -1,16 +0,0 @@
@echo off
if not exist ".git" (
exit /b 0
)
if not exist ".git\hooks" (
mkdir ".git\hooks"
)
(
echo #!/bin/sh
echo bun run typecheck
) > ".git\hooks\pre-push"
echo ✅ Pre-push hook installed

View file

@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.11.6",
"version": "0.12.1",
"publisher": "sst-dev",
"repository": {
"type": "git",

View file

@ -1,4 +1,5 @@
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {

View file

@ -1,7 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"customConditions": ["development"]
}
"compilerOptions": {}
}

View file

@ -5,6 +5,10 @@
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"opencode#test": {
"dependsOn": ["^build"],
"outputs": []
}
}
}