Compare commits

..

23 Commits

Author SHA1 Message Date
opencode ecafa40bcf release: v0.5.1 2025-08-14 20:47:45 +00:00
Dax Raad 25f4721c71 ci: aur is down 2025-08-14 16:42:39 -04:00
Dax Raad a433766a31 allow plugins to create custom auth providers 2025-08-14 16:25:08 -04:00
Jay V c93d50e8c7 ignore: lander tweaks 2025-08-14 15:53:41 -04:00
Jay V 3f879859d7 ignore: lander styles 2025-08-14 15:53:41 -04:00
Dax Raad ee62dc0745 wip: sdk 2025-08-14 12:22:27 -04:00
Dax Raad b7aefa715a ci: tweak 2025-08-14 12:20:22 -04:00
Dax Raad 796bc390db fix for session stuck in "Working..." 2025-08-14 12:20:22 -04:00
Lubos 703ae49675 chore: declare OpenAPI version 3.1.1 (#1931) 2025-08-14 12:10:32 -04:00
GitHub Action 37c0570a9f ignore: update download stats 2025-08-14 2025-08-14 12:04:42 +00:00
Aiden Cline 4dea0209bb fix: support fish shell (#1911) 2025-08-13 20:04:04 -05:00
Aiden Cline bb4b24a05f docs: fix bad example (#1913) 2025-08-13 20:03:44 -05:00
opencode e789abec79 release: v0.4.45 2025-08-13 22:32:26 +00:00
Aiden Cline 118617473e fix: bash should hide stdout from zshrc (#1909) 2025-08-13 17:04:32 -04:00
adamdotdevin a4beb60e19 chore: rename bash -> shell 2025-08-13 15:11:30 -05:00
Yuu Toriyama 3f0f910f7b Fix: Error [ERR_DLOPEN_FAILED] (#1546) 2025-08-13 19:49:14 +00:00
opencode 5bf841ab7a release: v0.4.44 2025-08-13 19:49:14 +00:00
Dax Raad 49727e3eab re-enable aur 2025-08-13 15:44:11 -04:00
Dax Raad 592c6ef97f ci: issue 2025-08-13 15:43:06 -04:00
envolution 00579f0ec1 Fix incorrect AUR namespace (#1907) 2025-08-13 15:37:15 -04:00
adamdotdevin 69d516c7fa fix: default scroll speed should be slower 2025-08-13 14:35:18 -05:00
Dax Raad bedeb626b2 docs: fix 2025-08-13 19:33:38 +00:00
Dominik Engelhardt a4c14dbb2d feat: convert attachments to text on delete (#1863)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
2025-08-13 19:33:38 +00:00
45 changed files with 1165 additions and 705 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- uses: oven-sh/setup-bun@v1
with:
bun-version: 1.2.17
bun-version: 1.2.19
- run: bun install
+10 -3
View File
@@ -23,7 +23,14 @@ jobs:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "*": "deny" }, "webfetch": "deny" }'
OPENCODE_PERMISSION: |
{
"bash": {
"gh issue*": "allow",
"*": "deny"
},
"webfetch": "deny"
}
run: |
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created: '${{ github.event.issue.title }}'
@@ -45,9 +52,9 @@ jobs:
- A suggestion to check those issues first
Use this format for the comment:
'👋 This issue might be a duplicate of existing issues. Please check:
'This issue might be a duplicate of existing issues. Please check:
- #[issue_number]: [brief description of similarity]
If none of these address your specific case, please let us know how this issue differs.'
Feel free to ignore if none of these address your specific case.'
If no clear duplicates are found, do not comment."
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.17
bun-version: 1.2.19
- run: git fetch --force --tags
- run: bun install -g @vscode/vsce
+5 -5
View File
@@ -48,10 +48,10 @@ jobs:
restore-keys: |
${{ runner.os }}-bun-
# - name: Install makepkg
# run: |
# sudo apt-get update
# sudo apt-get install -y pacman-package-manager
- name: Install makepkg
run: |
sudo apt-get update
sudo apt-get install -y pacman-package-manager
- name: Setup SSH for AUR
run: |
mkdir -p ~/.ssh
@@ -59,7 +59,7 @@ jobs:
chmod 600 ~/.ssh/id_rsa
git config --global user.email "opencode@sst.dev"
git config --global user.name "opencode"
# ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
- name: Install dependencies
run: bun install
+1
View File
@@ -47,3 +47,4 @@
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
+6
View File
@@ -113,6 +113,7 @@
"tree-sitter-bash": "0.23.3",
"turndown": "7.2.0",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.22.6",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -189,6 +190,9 @@
"sharp",
"esbuild",
"protobufjs",
"tree-sitter",
"web-tree-sitter",
"tree-sitter-bash",
],
"catalog": {
"@hono/zod-validator": "0.4.2",
@@ -2481,6 +2485,8 @@
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-tree-sitter": ["web-tree-sitter@0.22.6", "", {}, "sha512-hS87TH71Zd6mGAmYCvlgxeGDjqd9GTeqXNqTT+u0Gs51uIozNIaaq/kUAbV/Zf56jb2ZOyG8BxZs2GG9wbLi6Q=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cloud-core",
"version": "0.4.43",
"version": "0.5.1",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-function",
"version": "0.4.43",
"version": "0.5.1",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-web",
"version": "0.4.43",
"version": "0.5.1",
"private": true,
"description": "",
"type": "module",
+5 -2
View File
@@ -3,7 +3,7 @@
"name": "opencode",
"private": true,
"type": "module",
"packageManager": "bun@1.2.14",
"packageManager": "bun@1.2.19",
"scripts": {
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
@@ -46,7 +46,10 @@
"trustedDependencies": [
"esbuild",
"protobufjs",
"sharp"
"sharp",
"tree-sitter",
"tree-sitter-bash",
"web-tree-sitter"
],
"patchedDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.4.43",
"version": "0.5.1",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.4.43",
"version": "0.5.1",
"name": "opencode",
"type": "module",
"private": true,
@@ -52,6 +52,7 @@
"remeda": "catalog:",
"tree-sitter": "0.22.4",
"tree-sitter-bash": "0.23.3",
"web-tree-sitter": "0.22.6",
"turndown": "7.2.0",
"vscode-jsonrpc": "8.2.1",
"xdg-basedir": "5.1.0",
+39 -38
View File
@@ -97,45 +97,46 @@ 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())
// // AUR package
// const pkgbuild = [
// "# Maintainer: dax",
// "# Maintainer: adam",
// "",
// "pkgname='${pkg}'",
// `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')",
// "depends=('fzf' 'ripgrep')",
// "",
// `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip")`,
// `sha256sums_aarch64=('${arm64Sha}')`,
// "",
// `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip")`,
// `sha256sums_x86_64=('${x64Sha}')`,
// "",
// "package() {",
// ' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
// "}",
// "",
// ].join("\n")
/* AUR package - commented out as AUR is down
const pkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='${pkg}'",
`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')",
"depends=('fzf' 'ripgrep')",
"",
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-arm64.zip")`,
`sha256sums_aarch64=('${arm64Sha}')`,
"",
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${version}/opencode-linux-x64.zip")`,
`sha256sums_x86_64=('${x64Sha}')`,
"",
"package() {",
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
// for (const pkg of ["opencode"]) {
// await $`rm -rf ./dist/aur-${pkg}`
// await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
// await $`cd ./dist/aur-${pkg} && git checkout master`
// await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
// 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}"`
// if (!dry) await $`cd ./dist/aur-${pkg} && git push`
// }
for (const pkg of ["opencode-bin"]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
await $`cd ./dist/aur-${pkg} && git checkout master`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
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}"`
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
}
*/
// Homebrew formula
const homebrewFormula = [
-84
View File
@@ -1,84 +0,0 @@
import { generatePKCE } from "@openauthjs/openauth/pkce"
import { Auth } from "./index"
export namespace AuthAnthropic {
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
export async function authorize(mode: "max" | "console") {
const pkce = await generatePKCE()
const url = new URL(
`https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`,
import.meta.url,
)
url.searchParams.set("code", "true")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("response_type", "code")
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
url.searchParams.set("code_challenge", pkce.challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("state", pkce.verifier)
return {
url: url.toString(),
verifier: pkce.verifier,
}
}
export async function exchange(code: string, verifier: string) {
const splits = code.split("#")
const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
code: splits[0],
state: splits[1],
grant_type: "authorization_code",
client_id: CLIENT_ID,
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
code_verifier: verifier,
}),
})
if (!result.ok) throw new ExchangeFailed()
const json = await result.json()
return {
refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000,
}
}
export async function access() {
const info = await Auth.get("anthropic")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: info.refresh,
client_id: CLIENT_ID,
}),
})
if (!response.ok) return
const json = await response.json()
await Auth.set("anthropic", {
type: "oauth",
refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000,
})
return json.access_token as string
}
export class ExchangeFailed extends Error {
constructor() {
super("Exchange failed")
}
}
}
-19
View File
@@ -1,19 +0,0 @@
import { Global } from "../global"
import { lazy } from "../util/lazy"
import path from "path"
export const AuthCopilot = lazy(async () => {
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
const exists = await file.exists()
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
.then((x) => Bun.write(file, x))
.catch(() => {})
if (!exists) {
const worked = await response
if (!worked) return
}
const result = await import(file.name!).catch(() => {})
if (!result) return
return result.AuthCopilot
})
+22 -16
View File
@@ -4,25 +4,31 @@ import fs from "fs/promises"
import { z } from "zod"
export namespace Auth {
export const Oauth = z.object({
type: z.literal("oauth"),
refresh: z.string(),
access: z.string(),
expires: z.number(),
})
export const Oauth = z
.object({
type: z.literal("oauth"),
refresh: z.string(),
access: z.string(),
expires: z.number(),
})
.openapi({ ref: "OAuth" })
export const Api = z.object({
type: z.literal("api"),
key: z.string(),
})
export const Api = z
.object({
type: z.literal("api"),
key: z.string(),
})
.openapi({ ref: "ApiAuth" })
export const WellKnown = z.object({
type: z.literal("wellknown"),
key: z.string(),
token: z.string(),
})
export const WellKnown = z
.object({
type: z.literal("wellknown"),
key: z.string(),
token: z.string(),
})
.openapi({ ref: "WellKnownAuth" })
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown])
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" })
export type Info = z.infer<typeof Info>
const filepath = path.join(Global.Path.data, "auth.json")
+151 -214
View File
@@ -1,5 +1,3 @@
import { AuthAnthropic } from "../../auth/anthropic"
import { AuthCopilot } from "../../auth/copilot"
import { Auth } from "../../auth"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
@@ -10,6 +8,8 @@ import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { App } from "../../app/app"
export const AuthCommand = cmd({
command: "auth",
@@ -75,242 +75,179 @@ export const AuthLoginCommand = cmd({
type: "string",
}),
async handler(args) {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
stdout: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
prompts.log.error("Failed")
await App.provide({ cwd: process.cwd() }, async () => {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
stdout: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const token = await new Response(proc.stdout).text()
await Auth.set(args.url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + args.url)
prompts.outro("Done")
return
}
const token = await new Response(proc.stdout).text()
await Auth.set(args.url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + args.url)
prompts.outro("Done")
return
}
await ModelsDev.refresh().catch(() => {})
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
anthropic: 0,
"github-copilot": 1,
openai: 2,
google: 3,
openrouter: 4,
vercel: 5,
}
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] === 0 ? "recommended" : undefined,
})),
),
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
)
prompts.outro("Done")
return
}
if (provider === "anthropic") {
const method = await prompts.select({
message: "Login method",
await ModelsDev.refresh().catch(() => {})
const providers = await ModelsDev.get()
const priority: Record<string, number> = {
anthropic: 0,
"github-copilot": 1,
openai: 2,
google: 3,
openrouter: 4,
vercel: 5,
}
let provider = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: priority[x.id] === 0 ? "recommended" : undefined,
})),
),
{
label: "Claude Pro/Max",
value: "max",
},
{
label: "Create API Key",
value: "console",
},
{
label: "Manually enter API Key",
value: "api",
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
if (method === "max") {
// some weird bug where program exits without this
await new Promise((resolve) => setTimeout(resolve, 10))
const { url, verifier } = await AuthAnthropic.authorize("max")
prompts.note("Trying to open browser...")
try {
await open(url)
} catch (e) {
prompts.log.error(
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
)
}
prompts.log.info(url)
if (prompts.isCancel(provider)) throw new UI.CancelledError()
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
try {
const credentials = await AuthAnthropic.exchange(code, verifier)
await Auth.set("anthropic", {
type: "oauth",
refresh: credentials.refresh,
access: credentials.access,
expires: credentials.expires,
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
})
prompts.log.success("Login successful")
} catch {
prompts.log.error("Invalid code")
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
prompts.outro("Done")
return
}
const method = plugin.auth.methods[index]
if (method.type === "oauth") {
await new Promise((resolve) => setTimeout(resolve, 10))
const authorize = await method.authorize()
if (method === "console") {
// some weird bug where program exits without this
await new Promise((resolve) => setTimeout(resolve, 10))
const { url, verifier } = await AuthAnthropic.authorize("console")
prompts.note("Trying to open browser...")
try {
await open(url)
} catch (e) {
prompts.log.error(
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
)
}
prompts.log.info(url)
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
try {
const credentials = await AuthAnthropic.exchange(code, verifier)
const accessToken = credentials.access
const response = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json, text/plain, */*",
},
})
if (!response.ok) {
throw new Error("Failed to create API key")
if (authorize.url) {
try {
await open(authorize.url)
} catch (e) {}
prompts.log.info("Go to: " + authorize.url)
}
const json = await response.json()
await Auth.set("anthropic", {
type: "api",
key: json.raw_key,
})
prompts.log.success("Login successful - API key created and saved")
} catch (error) {
prompts.log.error("Invalid code or failed to create API key")
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
await Auth.set(provider, {
type: "oauth",
refresh: result.refresh,
access: result.access,
expires: result.expires,
})
prompts.log.success("Login successful")
}
}
prompts.outro("Done")
return
}
}
if (provider === "other") {
provider = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
)
prompts.outro("Done")
return
}
}
const copilot = await AuthCopilot()
if (provider === "github-copilot" && copilot) {
await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await copilot.authorize()
prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
while (true) {
await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
const response = await copilot.poll(deviceInfo.device)
if (response.status === "pending") continue
if (response.status === "success") {
await Auth.set("github-copilot", {
type: "oauth",
refresh: response.refresh,
access: response.access,
expires: response.expires,
})
spinner.stop("Login successful")
break
}
if (response.status === "failed") {
spinner.stop("Failed to authorize", 1)
break
}
if (provider === "vercel") {
prompts.log.info("You can create an api key in the dashboard")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, {
type: "api",
key,
})
prompts.outro("Done")
return
}
if (provider === "vercel") {
prompts.log.info("You can create an api key in the dashboard")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, {
type: "api",
key,
})
prompts.outro("Done")
},
})
+1
View File
@@ -4,6 +4,7 @@ export namespace Flag {
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
+19 -7
View File
@@ -6,6 +6,7 @@ import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Flag } from "../flag/flag"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -17,7 +18,17 @@ export namespace Plugin {
})
const config = await Config.get()
const hooks = []
for (let plugin of config.plugin ?? []) {
const input = {
client,
app,
$: Bun.$,
}
const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push("opencode-copilot-auth")
plugins.push("opencode-anthropic-auth")
}
for (let plugin of plugins) {
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const [pkg, version] = plugin.split("@")
@@ -25,22 +36,19 @@ export namespace Plugin {
}
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
const init = await fn({
client,
app,
$: Bun.$,
})
const init = await fn(input)
hooks.push(init)
}
}
return {
hooks,
input,
}
})
export async function trigger<
Name extends keyof Required<Hooks>,
Name extends Exclude<keyof Required<Hooks>, "auth" | "event">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
@@ -56,6 +64,10 @@ export namespace Plugin {
return output
}
export async function list() {
return state().then((x) => x.hooks)
}
export function init() {
Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)
+17 -97
View File
@@ -5,8 +5,7 @@ import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { AuthAnthropic } from "../auth/anthropic"
import { AuthCopilot } from "../auth/copilot"
import { Plugin } from "../plugin"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
@@ -26,103 +25,13 @@ export namespace Provider {
type Source = "env" | "config" | "custom" | "api"
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic(provider) {
const access = await AuthAnthropic.access()
if (!access)
return {
autoload: false,
options: {
headers: {
"anthropic-beta":
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
},
},
}
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
}
}
async anthropic() {
return {
autoload: true,
autoload: false,
options: {
apiKey: "",
async fetch(input: any, init: any) {
const access = await AuthAnthropic.access()
const headers = {
...init.headers,
authorization: `Bearer ${access}`,
"anthropic-beta":
"oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
},
},
}
},
"github-copilot": async (provider) => {
const copilot = await AuthCopilot()
if (!copilot) return { autoload: false }
let info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return { autoload: false }
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
}
}
}
return {
autoload: true,
options: {
apiKey: "",
async fetch(input: any, init: any) {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (!info.access || info.expires < Date.now()) {
const tokens = await copilot.access(info.refresh)
if (!tokens) throw new Error("GitHub Copilot authentication expired")
await Auth.set("github-copilot", {
type: "oauth",
...tokens,
})
info.access = tokens.access
}
let isAgentCall = false
let isVisionRequest = false
try {
const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
if (body?.messages) {
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
isVisionRequest = body.messages.some(
(msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
)
}
} catch {}
const headers: Record<string, string> = {
...init.headers,
...copilot.HEADERS,
Authorization: `Bearer ${info.access}`,
"Openai-Intent": "conversation-edits",
"X-Initiator": isAgentCall ? "agent" : "user",
}
if (isVisionRequest) {
headers["Copilot-Vision-Request"] = "true"
}
delete headers["x-api-key"]
return fetch(input, {
...init,
headers,
})
headers: {
"anthropic-beta":
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
},
},
}
@@ -350,6 +259,17 @@ export namespace Provider {
}
}
for (const plugin of await Plugin.list()) {
if (!plugin.auth) continue
const providerID = plugin.auth.provider
if (disabled.has(providerID)) continue
const auth = await Auth.get(providerID)
if (!auth) continue
if (!plugin.auth.loader) continue
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
mergeProvider(plugin.auth.provider, options ?? {}, "custom")
}
// load config
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")
+34 -2
View File
@@ -20,6 +20,7 @@ import { callTui, TuiRoute } from "./tui"
import { Permission } from "../permission"
import { lazy } from "../util/lazy"
import { Agent } from "../agent/agent"
import { Auth } from "../auth"
const ERRORS = {
400: {
@@ -88,7 +89,7 @@ export namespace Server {
version: "0.0.3",
description: "opencode api",
},
openapi: "3.0.0",
openapi: "3.1.1",
},
}),
)
@@ -1120,6 +1121,37 @@ export namespace Server {
async (c) => c.json(await callTui(c)),
)
.route("/tui/control", TuiRoute)
.put(
"/auth/:id",
describeRoute({
description: "Set authentication credentials",
operationId: "auth.set",
responses: {
200: {
description: "Successfully set authentication credentials",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...ERRORS,
},
}),
zValidator(
"param",
z.object({
id: z.string(),
}),
),
zValidator("json", Auth.Info),
async (c) => {
const id = c.req.valid("param").id
const info = c.req.valid("json")
await Auth.set(id, info)
return c.json(true)
},
)
return result
})
@@ -1133,7 +1165,7 @@ export namespace Server {
version: "1.0.0",
description: "opencode api",
},
openapi: "3.0.0",
openapi: "3.1.1",
},
})
return result
+17 -5
View File
@@ -45,6 +45,7 @@ import { Agent } from "../agent/agent"
import { Permission } from "../permission"
import { Wildcard } from "../util/wildcard"
import { ulid } from "ulid"
import { defer } from "../util/defer"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -763,6 +764,11 @@ export namespace Session {
sessionID: input.sessionID,
}
await updateMessage(assistantMsg)
await using _ = defer(async () => {
if (assistantMsg.time.completed) return
await Storage.remove(`session/message/${input.sessionID}/${assistantMsg.id}`)
await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: assistantMsg.id })
})
const tools: Record<string, AITool> = {}
const processor = createProcessor(assistantMsg, model.info)
@@ -1051,11 +1057,17 @@ export namespace Session {
await updatePart(part)
const app = App.info()
const script = `
[[ -f ~/.zshrc ]] && source ~/.zshrc 2>/dev/null || true
[[ -f ~/.bashrc ]] && source ~/.bashrc 2>/dev/null || true
eval "${input.command}"
`
const proc = spawn(process.env["SHELL"] ?? "bash", ["-c", "-l", script], {
[[ -f ~/.zshrc ]] && source ~/.zshrc >/dev/null 2>&1 || true
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
eval "${input.command}"
`
const shell = process.env["SHELL"] ?? "bash"
const isFish = shell.includes("fish")
const args = isFish
? ["-c", script] // fish with just -c
: ["-c", "-l", script]
const proc = spawn(shell, args, {
cwd: app.path.cwd,
signal: abort.signal,
env: {
+22 -5
View File
@@ -19,11 +19,28 @@ const MAX_TIMEOUT = 10 * 60 * 1000
const log = Log.create({ service: "bash-tool" })
const parser = lazy(async () => {
const { default: Parser } = await import("tree-sitter")
const Bash = await import("tree-sitter-bash")
const p = new Parser()
p.setLanguage(Bash.language as any)
return p
try {
const { default: Parser } = await import("tree-sitter")
const Bash = await import("tree-sitter-bash")
const p = new Parser()
p.setLanguage(Bash.language as any)
return p
} catch (e) {
const { default: Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" } })
await Parser.init({
locateFile() {
return treeWasm
},
})
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
const bashLanguage = await Parser.Language.load(bashWasm)
const p = new Parser()
p.setLanguage(bashLanguage)
return p
}
})
export const BashTool = Tool.define("bash", {
+43 -26
View File
@@ -1,53 +1,70 @@
import Parser from "tree-sitter";
import Bash from "tree-sitter-bash";
const parser = async () => {
try {
const { default: Parser } = await import("tree-sitter")
const Bash = await import("tree-sitter-bash")
const p = new Parser()
p.setLanguage(Bash.language as any)
return p
} catch (e) {
const { default: Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" } })
await Parser.init({
locateFile() {
return treeWasm
},
})
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
const bashLanguage = await Parser.Language.load(bashWasm)
const p = new Parser()
p.setLanguage(bashLanguage)
return p
}
}
const parser = new Parser();
parser.setLanguage(Bash.language as any);
const sourceCode = `cd --foo foo/bar && echo "hello" && cd ../baz`
const sourceCode = `cd --foo foo/bar && echo "hello" && cd ../baz`;
const tree = parser.parse(sourceCode);
const tree = await parser().then((p) => p.parse(sourceCode))
// Function to extract commands and arguments
function extractCommands(
node: any,
): Array<{ command: string; args: string[] }> {
const commands: Array<{ command: string; args: string[] }> = [];
function extractCommands(node: any): Array<{ command: string; args: string[] }> {
const commands: Array<{ command: string; args: string[] }> = []
function traverse(node: any) {
if (node.type === "command") {
const commandNode = node.child(0);
const commandNode = node.child(0)
if (commandNode) {
const command = commandNode.text;
const args: string[] = [];
const command = commandNode.text
const args: string[] = []
// Extract arguments
for (let i = 1; i < node.childCount; i++) {
const child = node.child(i);
const child = node.child(i)
if (child && child.type === "word") {
args.push(child.text);
args.push(child.text)
}
}
commands.push({ command, args });
commands.push({ command, args })
}
}
// Traverse children
for (let i = 0; i < node.childCount; i++) {
traverse(node.child(i));
traverse(node.child(i))
}
}
traverse(node);
return commands;
traverse(node)
return commands
}
// Extract and display commands
console.log("Source code: " + sourceCode);
const commands = extractCommands(tree.rootNode);
console.log("Extracted commands:");
console.log("Source code: " + sourceCode)
const commands = extractCommands(tree.rootNode)
console.log("Extracted commands:")
commands.forEach((cmd, index) => {
console.log(`${index + 1}. Command: ${cmd.command}`);
console.log(` Args: [${cmd.args.join(", ")}]`);
});
console.log(`${index + 1}. Command: ${cmd.command}`)
console.log(` Args: [${cmd.args.join(", ")}]`)
})
+12
View File
@@ -0,0 +1,12 @@
export function defer<T extends () => void | Promise<void>>(
fn: T,
): T extends () => Promise<void> ? { [Symbol.asyncDispose]: () => Promise<void> } : { [Symbol.dispose]: () => void } {
return {
[Symbol.dispose]() {
fn()
},
[Symbol.asyncDispose]() {
return Promise.resolve(fn())
},
} as any
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.4.43",
"version": "0.5.1",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+54 -1
View File
@@ -1,4 +1,14 @@
import type { Event, createOpencodeClient, App, Model, Provider, Permission, UserMessage, Part } from "@opencode-ai/sdk"
import type {
Event,
createOpencodeClient,
App,
Model,
Provider,
Permission,
UserMessage,
Part,
Auth,
} from "@opencode-ai/sdk"
import type { BunShell } from "./shell"
export type PluginInput = {
@@ -10,6 +20,49 @@ export type Plugin = (input: PluginInput) => Promise<Hooks>
export interface Hooks {
event?: (input: { event: Event }) => Promise<void>
auth?: {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
methods: (
| {
type: "oauth"
label: string
authorize(): Promise<
{ url: string; instructions: string } & (
| {
method: "auto"
callback(): Promise<
| {
type: "success"
refresh: string
access: string
expires: number
}
| {
type: "failed"
}
>
}
| {
method: "code"
callback(code: string): Promise<
| {
type: "success"
refresh: string
access: string
expires: number
}
| {
type: "failed"
}
>
}
)
>
}
| { type: "api"; label: string }
)[]
}
/**
* Called when a new message is received
*/
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.4.43",
"version": "0.5.1",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+20
View File
@@ -77,6 +77,9 @@ import type {
TuiClearPromptResponses,
TuiExecuteCommandData,
TuiExecuteCommandResponses,
AuthSetData,
AuthSetResponses,
AuthSetErrors,
} from "./types.gen.js"
import { client as _heyApiClient } from "./client.gen.js"
@@ -517,6 +520,22 @@ class Tui extends _HeyApiClient {
}
}
class Auth extends _HeyApiClient {
/**
* Set authentication credentials
*/
public set<ThrowOnError extends boolean = false>(options: Options<AuthSetData, ThrowOnError>) {
return (options.client ?? this._client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
url: "/auth/{id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
})
}
}
export class OpencodeClient extends _HeyApiClient {
/**
* Respond to a permission request
@@ -544,4 +563,5 @@ export class OpencodeClient extends _HeyApiClient {
find = new Find({ client: this._client })
file = new File({ client: this._client })
tui = new Tui({ client: this._client })
auth = new Auth({ client: this._client })
}
+143 -81
View File
@@ -22,18 +22,15 @@ export type Event =
| ({
type: "storage.write"
} & EventStorageWrite)
| ({
type: "file.edited"
} & EventFileEdited)
| ({
type: "server.connected"
} & EventServerConnected)
| ({
type: "permission.updated"
} & EventPermissionUpdated)
| ({
type: "permission.replied"
} & EventPermissionReplied)
| ({
type: "file.edited"
} & EventFileEdited)
| ({
type: "session.updated"
} & EventSessionUpdated)
@@ -46,6 +43,9 @@ export type Event =
| ({
type: "session.error"
} & EventSessionError)
| ({
type: "server.connected"
} & EventServerConnected)
| ({
type: "file.watcher.updated"
} & EventFileWatcherUpdated)
@@ -54,14 +54,14 @@ export type Event =
} & EventIdeInstalled)
export type EventInstallationUpdated = {
type: string
type: "installation.updated"
properties: {
version: string
}
}
export type EventLspClientDiagnostics = {
type: string
type: "lsp.client.diagnostics"
properties: {
serverID: string
path: string
@@ -69,7 +69,7 @@ export type EventLspClientDiagnostics = {
}
export type EventMessageUpdated = {
type: string
type: "message.updated"
properties: {
info: Message
}
@@ -86,7 +86,7 @@ export type Message =
export type UserMessage = {
id: string
sessionID: string
role: string
role: "user"
time: {
created: number
}
@@ -95,7 +95,7 @@ export type UserMessage = {
export type AssistantMessage = {
id: string
sessionID: string
role: string
role: "assistant"
time: {
created: number
completed?: number
@@ -135,7 +135,7 @@ export type AssistantMessage = {
}
export type ProviderAuthError = {
name: string
name: "ProviderAuthError"
data: {
providerID: string
message: string
@@ -143,28 +143,28 @@ export type ProviderAuthError = {
}
export type UnknownError = {
name: string
name: "UnknownError"
data: {
message: string
}
}
export type MessageOutputLengthError = {
name: string
name: "MessageOutputLengthError"
data: {
[key: string]: unknown
}
}
export type MessageAbortedError = {
name: string
name: "MessageAbortedError"
data: {
[key: string]: unknown
}
}
export type EventMessageRemoved = {
type: string
type: "message.removed"
properties: {
sessionID: string
messageID: string
@@ -172,7 +172,7 @@ export type EventMessageRemoved = {
}
export type EventMessagePartUpdated = {
type: string
type: "message.part.updated"
properties: {
part: Part
}
@@ -211,7 +211,7 @@ export type TextPart = {
id: string
sessionID: string
messageID: string
type: string
type: "text"
text: string
synthetic?: boolean
time?: {
@@ -224,7 +224,7 @@ export type ReasoningPart = {
id: string
sessionID: string
messageID: string
type: string
type: "reasoning"
text: string
metadata?: {
[key: string]: unknown
@@ -239,7 +239,7 @@ export type FilePart = {
id: string
sessionID: string
messageID: string
type: string
type: "file"
mime: string
filename?: string
url: string
@@ -256,7 +256,7 @@ export type FilePartSource =
export type FileSource = {
text: FilePartSourceText
type: string
type: "file"
path: string
}
@@ -268,7 +268,7 @@ export type FilePartSourceText = {
export type SymbolSource = {
text: FilePartSourceText
type: string
type: "symbol"
path: string
range: Range
name: string
@@ -290,7 +290,7 @@ export type ToolPart = {
id: string
sessionID: string
messageID: string
type: string
type: "tool"
callID: string
tool: string
state: ToolState
@@ -311,11 +311,11 @@ export type ToolState =
} & ToolStateError)
export type ToolStatePending = {
status: string
status: "pending"
}
export type ToolStateRunning = {
status: string
status: "running"
input?: unknown
title?: string
metadata?: {
@@ -327,7 +327,7 @@ export type ToolStateRunning = {
}
export type ToolStateCompleted = {
status: string
status: "completed"
input: {
[key: string]: unknown
}
@@ -343,7 +343,7 @@ export type ToolStateCompleted = {
}
export type ToolStateError = {
status: string
status: "error"
input: {
[key: string]: unknown
}
@@ -358,14 +358,14 @@ export type StepStartPart = {
id: string
sessionID: string
messageID: string
type: string
type: "step-start"
}
export type StepFinishPart = {
id: string
sessionID: string
messageID: string
type: string
type: "step-finish"
cost: number
tokens: {
input: number
@@ -382,7 +382,7 @@ export type SnapshotPart = {
id: string
sessionID: string
messageID: string
type: string
type: "snapshot"
snapshot: string
}
@@ -390,7 +390,7 @@ export type PatchPart = {
id: string
sessionID: string
messageID: string
type: string
type: "patch"
hash: string
files: Array<string>
}
@@ -399,7 +399,7 @@ export type AgentPart = {
id: string
sessionID: string
messageID: string
type: string
type: "agent"
name: string
source?: {
value: string
@@ -409,7 +409,7 @@ export type AgentPart = {
}
export type EventMessagePartRemoved = {
type: string
type: "message.part.removed"
properties: {
sessionID: string
messageID: string
@@ -418,29 +418,15 @@ export type EventMessagePartRemoved = {
}
export type EventStorageWrite = {
type: string
type: "storage.write"
properties: {
key: string
content?: unknown
}
}
export type EventFileEdited = {
type: string
properties: {
file: string
}
}
export type EventServerConnected = {
type: string
properties: {
[key: string]: unknown
}
}
export type EventPermissionUpdated = {
type: string
type: "permission.updated"
properties: Permission
}
@@ -461,7 +447,7 @@ export type Permission = {
}
export type EventPermissionReplied = {
type: string
type: "permission.replied"
properties: {
sessionID: string
permissionID: string
@@ -469,8 +455,15 @@ export type EventPermissionReplied = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventSessionUpdated = {
type: string
type: "session.updated"
properties: {
info: Session
}
@@ -497,21 +490,21 @@ export type Session = {
}
export type EventSessionDeleted = {
type: string
type: "session.deleted"
properties: {
info: Session
}
}
export type EventSessionIdle = {
type: string
type: "session.idle"
properties: {
sessionID: string
}
}
export type EventSessionError = {
type: string
type: "session.error"
properties: {
sessionID?: string
error?:
@@ -530,16 +523,23 @@ export type EventSessionError = {
}
}
export type EventServerConnected = {
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type EventFileWatcherUpdated = {
type: string
type: "file.watcher.updated"
properties: {
file: string
event: string
event: "rename" | "change"
}
}
export type EventIdeInstalled = {
type: string
type: "ide.installed"
properties: {
ide: string
}
@@ -569,6 +569,9 @@ export type Config = {
* Theme name to use for the interface
*/
theme?: string
/**
* Custom keybind configurations
*/
keybinds?: KeybindsConfig
plugin?: Array<string>
snapshot?: boolean
@@ -683,7 +686,7 @@ export type Config = {
lsp?: {
[key: string]:
| {
disabled: boolean
disabled: true
}
| {
command: Array<string>
@@ -701,15 +704,18 @@ export type Config = {
* Additional instruction files or patterns to include
*/
instructions?: Array<string>
/**
* @deprecated Always uses stretch layout.
*/
layout?: LayoutConfig
permission?: {
edit?: string
edit?: "ask" | "allow" | "deny"
bash?:
| string
| ("ask" | "allow" | "deny")
| {
[key: string]: string
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: string
webfetch?: "ask" | "allow" | "deny"
}
experimental?: {
hook?: {
@@ -911,15 +917,15 @@ export type AgentConfig = {
* Description of when to use the agent
*/
description?: string
mode?: string
mode?: "subagent" | "primary" | "all"
permission?: {
edit?: string
edit?: "ask" | "allow" | "deny"
bash?:
| string
| ("ask" | "allow" | "deny")
| {
[key: string]: string
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: string
webfetch?: "ask" | "allow" | "deny"
}
[key: string]:
| unknown
@@ -929,15 +935,15 @@ export type AgentConfig = {
[key: string]: boolean
}
| boolean
| string
| ("subagent" | "primary" | "all")
| {
edit?: string
edit?: "ask" | "allow" | "deny"
bash?:
| string
| ("ask" | "allow" | "deny")
| {
[key: string]: string
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: string
webfetch?: "ask" | "allow" | "deny"
}
| undefined
}
@@ -980,7 +986,7 @@ export type McpLocalConfig = {
/**
* Type of MCP server connection
*/
type: string
type: "local"
/**
* Command and arguments to run the MCP server
*/
@@ -1001,7 +1007,7 @@ export type McpRemoteConfig = {
/**
* Type of MCP server connection
*/
type: string
type: "remote"
/**
* URL of the remote MCP server
*/
@@ -1028,7 +1034,7 @@ export type _Error = {
export type TextPartInput = {
id?: string
type: string
type: "text"
text: string
synthetic?: boolean
time?: {
@@ -1039,7 +1045,7 @@ export type TextPartInput = {
export type FilePartInput = {
id?: string
type: string
type: "file"
mime: string
filename?: string
url: string
@@ -1048,7 +1054,7 @@ export type FilePartInput = {
export type AgentPartInput = {
id?: string
type: string
type: "agent"
name: string
source?: {
value: string
@@ -1076,15 +1082,15 @@ export type File = {
export type Agent = {
name: string
description?: string
mode: string
mode: "subagent" | "primary" | "all"
topP?: number
temperature?: number
permission: {
edit: string
edit: "ask" | "allow" | "deny"
bash: {
[key: string]: string
[key: string]: "ask" | "allow" | "deny"
}
webfetch?: string
webfetch?: "ask" | "allow" | "deny"
}
model?: {
modelID: string
@@ -1099,6 +1105,35 @@ export type Agent = {
}
}
export type Auth =
| ({
type: "oauth"
} & OAuth)
| ({
type: "api"
} & ApiAuth)
| ({
type: "wellknown"
} & WellKnownAuth)
export type OAuth = {
type: "oauth"
refresh: string
access: string
expires: number
}
export type ApiAuth = {
type: "api"
key: string
}
export type WellKnownAuth = {
type: "wellknown"
key: string
token: string
}
export type EventSubscribeData = {
body?: never
path?: never
@@ -1852,6 +1887,33 @@ export type TuiExecuteCommandResponses = {
export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]
export type AuthSetData = {
body?: Auth
path: {
id: string
}
query?: never
url: "/auth/{id}"
}
export type AuthSetErrors = {
/**
* Bad request
*/
400: _Error
}
export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
export type AuthSetResponses = {
/**
* Successfully set authentication credentials
*/
200: boolean
}
export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {})
}
+26
View File
@@ -2,8 +2,34 @@ import { createClient } from "./gen/client/client.js"
import { type Config } from "./gen/client/types.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
export * from "./gen/types.gen.js"
import { spawn } from "child_process"
export function createOpencodeClient(config?: Config) {
const client = createClient(config)
return new OpencodeClient({ client })
}
export type ServerConfig = {
host?: string
port?: number
}
export async function createOpencodeServer(config?: ServerConfig) {
config = Object.assign(
{
host: "127.0.0.1",
port: 4096,
},
config ?? {},
)
const proc = spawn(`opencode`, [`serve`, `--host=${config.host}`, `--port=${config.port}`])
const url = `http://${config.host}:${config.port}`
return {
url,
close() {
proc.kill()
},
}
}
+5 -5
View File
@@ -80,7 +80,7 @@ type AgentSelectedMsg struct {
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendPrompt = Prompt
type SendBash = struct {
type SendShell = struct {
Command string
}
type SetEditorContentMsg struct {
@@ -631,7 +631,7 @@ func (a *App) IsBusy() bool {
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return true
return false
}
func (a *App) SaveState() tea.Cmd {
@@ -755,7 +755,7 @@ func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
return a, tea.Batch(cmds...)
}
func (a *App) SendBash(ctx context.Context, command string) (*App, tea.Cmd) {
func (a *App) SendShell(ctx context.Context, command string) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
@@ -776,8 +776,8 @@ func (a *App) SendBash(ctx context.Context, command string) (*App, tea.Cmd) {
},
)
if err != nil {
slog.Error("Failed to submit bash command", "error", err)
return toast.NewErrorToast("Failed to submit bash command")()
slog.Error("Failed to submit shell command", "error", err)
return toast.NewErrorToast("Failed to submit shell command")()
}
return nil
})
@@ -500,7 +500,7 @@ func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) {
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(app.SendBash{Command: command}))
cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command}))
return m, tea.Batch(cmds...)
}
@@ -687,6 +687,8 @@ func renderToolDetails(
func renderToolName(name string) string {
switch name {
case "bash":
return "Shell"
case "webfetch":
return "Fetch"
case "invalid":
@@ -203,6 +203,11 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Properties.Part.SessionID == m.app.Session.ID {
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessageRemoved:
if msg.Properties.SessionID == m.app.Session.ID {
m.cache.Clear()
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessagePartRemoved:
if msg.Properties.SessionID == m.app.Session.ID {
// Clear the cache when a part is removed to ensure proper re-rendering
@@ -1153,7 +1158,7 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
if app.State.ScrollSpeed != nil && *app.State.ScrollSpeed > 0 {
vp.MouseWheelDelta = *app.State.ScrollSpeed
} else {
vp.MouseWheelDelta = 4
vp.MouseWheelDelta = 2
}
// Default to showing tool details, hidden thinking blocks
@@ -670,6 +670,28 @@ func (m *Model) InsertAttachment(att *attachment.Attachment) {
m.SetCursorColumn(m.col)
}
// removeAttachmentAtCursor replaces the attachment at or immediately before the
// cursor with its textual display and positions the cursor at the end of the
// inserted text. Returns true if an attachment was removed.
func (m *Model) removeAttachmentAtCursor() bool {
att, startIdx, _ := m.isAttachmentAtCursor()
if att == nil {
return false
}
// Replace the attachment element with the display runes
before := m.value[m.row][:startIdx]
after := m.value[m.row][startIdx+1:]
replacement := runesToInterfaces([]rune(att.Display))
newRow := make([]any, 0, len(before)+len(replacement)+len(after))
newRow = append(newRow, before...)
newRow = append(newRow, replacement...)
newRow = append(newRow, after...)
m.value[m.row] = newRow
m.col = startIdx + len(replacement)
m.SetCursorColumn(m.col)
return true
}
// ReplaceRange replaces text from startCol to endCol on the current row with the given string.
// This preserves attachments outside the replaced range.
func (m *Model) ReplaceRange(startCol, endCol int, replacement string) {
@@ -1577,6 +1599,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
m.deleteBeforeCursor()
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
// If the cursor is at or just after an attachment, convert it to text instead of deleting
if att, _, _ := m.isAttachmentAtCursor(); att != nil {
if m.removeAttachmentAtCursor() {
break
}
}
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col <= 0 {
m.mergeLineAbove(m.row)
@@ -1587,6 +1615,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.SetCursorColumn(m.col - 1)
}
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
// If the cursor is on an attachment, convert it to text instead of deleting
if att, _, _ := m.isAttachmentAtCursor(); att != nil {
if m.removeAttachmentAtCursor() {
break
}
}
if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1)
}
@@ -0,0 +1,75 @@
package textarea
import (
"testing"
"github.com/sst/opencode/internal/attachment"
)
func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorAfterAttachment(t *testing.T) {
m := New()
m.InsertString("a ")
att := &attachment.Attachment{ID: "1", Display: "@file.txt"}
m.InsertAttachment(att)
m.InsertString(" b")
// Position cursor immediately after the attachment (index 3: 'a',' ',att,' ', 'b')
m.SetCursorColumn(3)
if ok := m.removeAttachmentAtCursor(); !ok {
t.Fatalf("expected removal to occur")
}
got := m.Value()
want := "a @file.txt b"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorOnAttachment(t *testing.T) {
m := New()
m.InsertString("x ")
att := &attachment.Attachment{ID: "2", Display: "@img.png"}
m.InsertAttachment(att)
m.InsertString(" y")
// Position cursor on the attachment token (index 2: 'x',' ',att,' ', 'y')
m.SetCursorColumn(2)
if ok := m.removeAttachmentAtCursor(); !ok {
t.Fatalf("expected removal to occur")
}
got := m.Value()
want := "x @img.png y"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestRemoveAttachmentAtCursor_StartOfLine(t *testing.T) {
m := New()
att := &attachment.Attachment{ID: "3", Display: "@a.txt"}
m.InsertAttachment(att)
m.InsertString(" tail")
// Position cursor immediately after the attachment at start of line (index 1)
m.SetCursorColumn(1)
if ok := m.removeAttachmentAtCursor(); !ok {
t.Fatalf("expected removal to occur at start of line")
}
if got := m.Value(); got != "@a.txt tail" {
t.Fatalf("unexpected value: %q", got)
}
}
func TestRemoveAttachmentAtCursor_NoAttachment_NoChange(t *testing.T) {
m := New()
m.InsertString("hello world")
col := m.CursorColumn()
if ok := m.removeAttachmentAtCursor(); ok {
t.Fatalf("did not expect removal to occur")
}
if m.Value() != "hello world" || m.CursorColumn() != col {
t.Fatalf("value or cursor unexpectedly changed")
}
}
+2 -2
View File
@@ -402,8 +402,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showCompletionDialog = false
a.app, cmd = a.app.SendPrompt(context.Background(), msg)
cmds = append(cmds, cmd)
case app.SendBash:
a.app, cmd = a.app.SendBash(context.Background(), msg.Command)
case app.SendShell:
a.app, cmd = a.app.SendShell(context.Background(), msg.Command)
cmds = append(cmds, cmd)
case app.SetEditorContentMsg:
// Set the editor content without sending
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
"version": "0.4.43",
"version": "0.5.1",
"scripts": {
"dev": "astro dev",
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

+378 -59
View File
@@ -5,7 +5,9 @@ import type { Props } from '@astrojs/starlight/props';
import CopyIcon from "../assets/lander/copy.svg";
import CheckIcon from "../assets/lander/check.svg";
import Screenshot from "../assets/lander/screenshot-splash.png";
import TuiScreenshot from "../assets/lander/screenshot-splash.png";
import VscodeScreenshot from "../assets/lander/screenshot-vscode.png";
import GithubScreenshot from "../assets/lander/screenshot-github.png";
const { data } = Astro.locals.starlightRoute.entry;
const { title = data.title, tagline, image, actions = [] } = data.hero || {};
@@ -18,6 +20,7 @@ const imageAttrs = {
};
const github = config.social.filter(s => s.icon === 'github')[0];
const discord = config.social.filter(s => s.icon === 'discord')[0];
const command = "curl -fsSL"
const protocol = "https://"
@@ -35,7 +38,7 @@ if (image) {
lightImage = image.light;
} else {
rawHtml = image.html;
}
}
}
---
<div class="hero">
@@ -53,7 +56,7 @@ if (image) {
<section class="cta">
<div class="col1">
<a href="/docs">Docs</a>
<a href="/docs">Get Started</a>
</div>
<div class="col2">
<button class="command" data-command={`${command} ${protocol}${url} ${bash}`}>
@@ -66,9 +69,6 @@ if (image) {
</span>
</button>
</div>
<div class="col3">
<a href={github.href}>GitHub</a>
</div>
</section>
<section class="content">
@@ -82,19 +82,95 @@ if (image) {
</ul>
</section>
<section class="alternatives">
<div class="col1">
<h3>npm</h3>
<button class="command" data-command="npm install -g opencode-ai">
<code>
<span>npm install -g</span> <span class="highlight">opencode-ai</span>
</code>
<span class="copy">
<CopyIcon />
<CheckIcon />
</span>
</button>
</div>
<div class="col2">
<h3>Bun</h3>
<button class="command" data-command="bun install -g opencode-ai">
<code>
<span>bun install -g</span> <span class="highlight">opencode-ai</span>
</code>
<span class="copy">
<CopyIcon />
<CheckIcon />
</span>
</button>
</div>
<div class="col3">
<h3>Homebrew</h3>
<button class="command" data-command="brew install sst/tap/opencode">
<code>
<span>brew install</span> <span class="highlight">sst/tap/opencode</span>
</code>
<span class="copy">
<CopyIcon />
<CheckIcon />
</span>
</button>
</div>
<div class="col4">
<h3>Paru</h3>
<button class="command" data-command="paru -S opencode-bin">
<code>
<span>paru -S</span> <span class="highlight">opencode-bin</span>
</code>
<span class="copy">
<CopyIcon />
<CheckIcon />
</span>
</button>
</div>
</section>
<section class="images">
<div>
<p>opencode TUI with the tokyonight theme</p>
<Image width={600} src={Screenshot} alt="opencode TUI with the tokyonight theme" />
<div class="left">
<figure>
<figcaption>opencode TUI with the tokyonight theme</figcaption>
<a href="/docs/cli">
<Image src={TuiScreenshot} alt="opencode TUI with the tokyonight theme" />
</a>
</figure>
</div>
<div class="right">
<div class="row1">
<figure>
<figcaption>opencode in VS Code</figcaption>
<a href="/docs/ide">
<Image src={VscodeScreenshot} alt="opencode in VS Code" />
</a>
</figure>
</div>
<div class="row2">
<figure>
<figcaption>opencode in GitHub</figcaption>
<a href="/docs/github">
<Image src={GithubScreenshot} alt="opencode in GitHub" />
</a>
</figure>
</div>
</div>
</section>
<section class="footer">
<div class="col1">
<span>Version: Beta</span>
<a href={github.href} target="_blank" rel="noopener noreferrer">GitHub</a>
</div>
<div class="col2">
<span>Author: <a href="https://sst.dev">SST</a></span>
<a href={discord.href} target="_blank" rel="noopener noreferrer">Discord</a>
</div>
<div class="col3">
<span>&copy;2025 <a href="https://anoma.ly" target="_blank" rel="noopener noreferrer">Anomaly Innovations</a></span>
</div>
</section>
</div>
@@ -102,7 +178,7 @@ if (image) {
<style>
.hero {
--padding: 3rem;
--vertical-padding: 2rem;
--vertical-padding: 1.5rem;
--heading-font-size: var(--sl-text-3xl);
margin: 1rem;
@@ -111,7 +187,7 @@ if (image) {
@media (max-width: 30rem) {
.hero {
--padding: 1rem;
--vertical-padding: 1rem;
--vertical-padding: 0.75rem;
--heading-font-size: var(--sl-text-2xl);
margin: 0.5rem;
@@ -143,31 +219,29 @@ section.cta {
@media (max-width: 50rem) {
flex-direction: column;
& > div.col1 { order: 1; }
& > div.col3 { order: 2; }
& > div.col2 { order: 3; }
}
& > div {
line-height: 1.4;
padding: calc(var(--padding) / 2) 1rem;
padding: var(--vertical-padding) var(--padding);
a {
font-size: 1rem;
}
}
& > div.col1, & > div.col3 {
flex: 1 1 auto;
& > div.col1 {
flex: 0 0 auto;
text-align: center;
text-transform: uppercase;
@media (max-width: 50rem) {
padding-bottom: calc(var(--padding) / 2 + 4px);
padding-bottom: calc(var(--vertical-padding) + 4px);
}
}
& > div.col2 {
flex: 0 0 auto;
flex: 1;
padding-left: 1rem;
padding-right: 1rem;
}
& > div + div {
@@ -261,13 +335,40 @@ section.content {
}
section.images {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
--images-height: 600px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: var(--images-height);
border-top: 2px solid var(--sl-color-border);
& > div {
& > div.left {
display: flex;
grid-row: 1;
grid-column: 1;
}
& > div.right {
display: grid;
grid-template-rows: 1fr 1fr;
grid-row: 1;
grid-column: 2;
border-left: 2px solid var(--sl-color-border);
& > div.row1 {
display: flex;
grid-row: 1;
border-bottom: 2px solid var(--sl-color-border);
height: calc(var(--images-height) / 2);
}
& > div.row2 {
display: flex;
grid-row: 2;
height: calc(var(--images-height) / 2);
}
}
figure {
flex: 1;
display: flex;
flex-direction: column;
@@ -276,51 +377,237 @@ section.images {
border-width: 0;
border-style: solid;
border-color: var(--sl-color-border);
min-height: 0;
overflow: hidden;
& > div, p {
flex: 1;
& > div, figcaption {
display: flex;
align-items: center;
}
}
p {
letter-spacing: -0.03125rem;
text-transform: uppercase;
color: var(--sl-color-text-dimmed);
& > div {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 30rem) {
font-size: 0.75rem;
a {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
figcaption {
letter-spacing: -0.03125rem;
text-transform: uppercase;
color: var(--sl-color-text-dimmed);
flex-shrink: 0;
@media (max-width: 30rem) {
font-size: 0.75rem;
}
}
}
img {
align-self: center;
width: 100%;
max-width: 600px;
height: auto;
& > div.left figure {
height: var(--images-height);
box-sizing: border-box;
}
& > div + div {
border-width: 0 0 0 2px;
& > div.right figure {
height: calc(var(--images-height) / 2);
box-sizing: border-box;
}
& > div.left img {
width: 100%;
height: 100%;
min-width: 0;
object-fit: contain;
}
& > div.right img {
width: 100%;
height: calc(100% - 2rem);
object-fit: contain;
display: block;
}
@media (max-width: 30rem) {
& {
flex-direction: column;
--images-height: auto;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
& > div + div {
border-width: 2px 0 0 0;
& > div.left {
grid-row: 1;
grid-column: 1;
}
& > div.right {
grid-row: 2;
grid-column: 1;
border-left: none;
border-top: 2px solid var(--sl-color-border);
& > div.row1,
& > div.row2 {
height: auto;
}
}
& > div.left figure,
& > div.right figure {
height: auto;
}
& > div.left img,
& > div.right img {
width: 100%;
height: auto;
max-height: none;
}
}
}
section.approach {
section.alternatives {
border-top: 2px solid var(--sl-color-border);
padding: var(--padding);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
p + p {
margin-top: var(--vertical-padding);
@media (max-width: 40rem) {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
& > div {
display: flex;
flex-direction: column;
padding: calc(var(--vertical-padding) / 2) calc(var(--padding) / 2) calc(var(--vertical-padding) / 2 + 0.125rem);
text-align: left;
gap: 0.5rem;
@media (max-width: 40rem) {
text-align: left;
}
}
& > div.col1 {
border-bottom: 2px solid var(--sl-color-border);
@media (max-width: 40rem) {
border-bottom: none;
}
}
& > div.col2 {
border-left: 2px solid var(--sl-color-border);
border-bottom: 2px solid var(--sl-color-border);
@media (max-width: 40rem) {
border-left: none;
border-bottom: none;
border-top: 2px solid var(--sl-color-border);
}
}
& > div.col3 {
@media (max-width: 40rem) {
border-top: 2px solid var(--sl-color-border);
}
}
& > div.col4 {
border-left: 2px solid var(--sl-color-border);
@media (max-width: 40rem) {
border-left: none;
border-top: 2px solid var(--sl-color-border);
}
}
h3 {
letter-spacing: -0.03125rem;
text-transform: uppercase;
color: var(--sl-color-text-dimmed);
font-weight: normal;
font-size: 1rem;
flex-shrink: 0;
}
.command {
all: unset;
display: flex;
align-items: center;
gap: 0.625rem;
justify-content: flex-start;
cursor: pointer;
width: 100%;
@media (max-width: 40rem) {
justify-content: flex-start;
}
@media (max-width: 30rem) {
justify-content: center;
}
code {
color: var(--sl-color-text-secondary);
font-size: 1rem;
@media (max-width: 24rem) {
font-size: 0.875rem;
}
}
code .highlight {
color: var(--sl-color-text);
font-weight: 500;
}
.copy {
line-height: 1;
padding: 0;
@media (max-width: 40rem) {
display: none;
}
}
.copy svg {
width: 1rem;
height: 1rem;
vertical-align: middle;
}
.copy svg:first-child {
color: var(--sl-color-text-dimmed);
}
.copy svg:last-child {
color: var(--sl-color-text);
display: none;
}
&.success .copy {
pointer-events: none;
}
&.success .copy svg:first-child {
display: none;
}
&.success .copy svg:last-child {
display: inline;
}
}
}
@@ -333,12 +620,42 @@ section.footer {
flex: 1;
text-align: center;
text-transform: uppercase;
padding: calc(var(--padding) / 2) 0.5rem;
padding: var(--vertical-padding) 0.5rem;
}
& > div + div {
border-left: 2px solid var(--sl-color-border);
}
/* Below 800px: first two columns shrink to content, third expands */
@media (max-width: 50rem) {
& > div.col1,
& > div.col2 {
flex: 0 0 auto;
padding-left: calc(var(--padding) / 2);
padding-right: calc(var(--padding) / 2);
}
& > div.col3 {
flex: 1;
}
}
/* Mobile: third column on its own row */
@media (max-width: 30rem) {
flex-wrap: wrap;
& > div.col1,
& > div.col2 {
flex: 1;
}
& > div.col3 {
flex: 1 0 100%;
border-left: none;
border-top: 2px solid var(--sl-color-border);
}
}
}
</style>
@@ -361,13 +678,15 @@ section.footer {
</style>
<script>
const button = document.querySelector("button.command") as HTMLButtonElement;
const buttons = document.querySelectorAll("button.command") as NodeListOf<HTMLButtonElement>
button?.addEventListener("click", () => {
navigator.clipboard.writeText(button.dataset.command!);
button.classList.toggle("success");
setTimeout(() => {
button.classList.toggle("success");
}, 1500);
});
buttons.forEach(button => {
button.addEventListener("click", () => {
navigator.clipboard.writeText(button.dataset.command!)
button.classList.toggle("success")
setTimeout(() => {
button.classList.toggle("success");
}, 1500)
})
})
</script>
@@ -103,20 +103,6 @@ Start a line with `!` to run a shell command. The output is added to the convers
!ls -la
```
Commands run in your session's working directory and share state across runs. Environment variables and directory changes persist while the session is open.
```bash frame="none"
!export FOO=bar
!echo $FOO
```
Chain commands with `&&` or `;`, and quote paths with spaces. Use absolute paths when possible for reliability.
```bash frame="none"
!npm install && npm test
!python "/path/with spaces/script.py"
```
---
### Editor setup
@@ -93,13 +93,9 @@ Prevent opencode from reading `.env` files:
```javascript title=".opencode/plugin/env-protection.js"
export const EnvProtection = async ({ client, $ }) => {
return {
tool: {
execute: {
before: async (input, output) => {
if (input.tool === "read" && output.args.filePath.includes(".env")) {
throw new Error("Do not read .env files")
}
}
"tool.execute.before": async (input, output) => {
if (input.tool === "read" && output.args.filePath.includes(".env")) {
throw new Error("Do not read .env files")
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.4.43",
"version": "0.5.1",
"publisher": "sst-dev",
"repository": {
"type": "git",