mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afb8a0d28a | |||
| f05f175842 | |||
| c0b214232d | |||
| f4f8f2d151 | |||
| b4ad5c138e | |||
| 789e111a0f | |||
| eaa76dad0c | |||
| 66f9bdab32 | |||
| efaa9166fb | |||
| eb2044989e | |||
| 5d37e58d34 | |||
| 20088a87b0 | |||
| 05019dae76 | |||
| bf37a88f7f | |||
| d839f70834 | |||
| 498a4ab408 | |||
| 835e48cd28 | |||
| 1a5a63843e | |||
| d954e1e3b6 | |||
| 8f22a6b69d | |||
| fd37d5b54e | |||
| e146083b73 | |||
| d7a1c268d9 | |||
| 5c4345da4f | |||
| 0be37cc2c6 | |||
| 64f0205f97 | |||
| b3a1360ad9 | |||
| 08d4d6d4af | |||
| 9c69c1de9f | |||
| 735f3d17bc |
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -126,7 +126,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -150,7 +150,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -174,7 +174,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -232,7 +232,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -248,7 +248,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -351,7 +351,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -371,7 +371,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -382,7 +382,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -395,7 +395,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -407,6 +407,7 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"dompurify": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
@@ -434,7 +435,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -445,7 +446,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -507,6 +508,7 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "5.0.119",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -1828,6 +1830,8 @@
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/tsscmp": ["@types/tsscmp@1.0.2", "", {}, "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g=="],
|
||||
|
||||
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
|
||||
@@ -2280,6 +2284,8 @@
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
|
||||
|
||||
@@ -163,6 +163,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
ZEN_BLACK,
|
||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||
...ZEN_MODELS,
|
||||
...($dev
|
||||
? [
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-8nur5CuUCSV/SzD16hNXVoIlKsiPBXDzCnoITK0IhC4=",
|
||||
"aarch64-darwin": "sha256-vD1g9dviI2nMBTTPwI87sK01hSZ+cdnmb1V72AdJYq4="
|
||||
"x86_64-linux": "sha256-UCPTTk4b7d2bets7KgCeYBHWAUwUAPUyKm+xDYkSexE=",
|
||||
"aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
|
||||
if [ ! -f "$HASH_FILE" ]; then
|
||||
cat >"$HASH_FILE" <<EOF
|
||||
{
|
||||
"nodeModules": "$DUMMY"
|
||||
"nodeModules": {}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
@@ -111,7 +111,7 @@ fi
|
||||
|
||||
write_node_modules_hash "$CORRECT_HASH"
|
||||
|
||||
jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
|
||||
jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null
|
||||
|
||||
echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"ai": "5.0.119",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -116,6 +117,10 @@ export function DialogSelectServer() {
|
||||
select(value, true)
|
||||
}
|
||||
|
||||
async function handleRemove(url: string) {
|
||||
server.remove(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
|
||||
<div class="flex flex-col gap-4 pb-4">
|
||||
@@ -130,20 +135,33 @@ export function DialogSelectServer() {
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div
|
||||
class="flex items-center gap-2 min-w-0 flex-1"
|
||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1 group/item">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||
"bg-border-weak-base": store.status[i] === undefined,
|
||||
}}
|
||||
/>
|
||||
<span class="truncate">{serverDisplayName(i)}</span>
|
||||
<span class="text-text-weak">{store.status[i]?.version}</span>
|
||||
class="flex items-center gap-2 min-w-0 flex-1"
|
||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||
"bg-border-weak-base": store.status[i] === undefined,
|
||||
}}
|
||||
/>
|
||||
<span class="truncate">{serverDisplayName(i)}</span>
|
||||
<span class="text-text-weak">{store.status[i]?.version}</span>
|
||||
</div>
|
||||
<Show when={current() !== i && server.list.includes(i)}>
|
||||
<IconButton
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
class="bg-transparent transition-opacity shrink-0 hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(i)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
|
||||
@@ -9,11 +9,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
name: "GlobalSDK",
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventSdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
signal: abort.signal,
|
||||
fetch: platform.fetch,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
@@ -93,7 +95,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
stop()
|
||||
})
|
||||
|
||||
const platform = usePlatform()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
fetch: platform.fetch,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ErrorPage, type InitError } from "../pages/error"
|
||||
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
@@ -64,6 +65,7 @@ type State = {
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
const [globalStore, setGlobalStore] = createStore<{
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
@@ -139,6 +141,7 @@ function createGlobalSync() {
|
||||
const [store, setStore] = child(directory)
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
@@ -396,6 +399,7 @@ function createGlobalSync() {
|
||||
case "lsp.updated": {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
export type LocalPTY = {
|
||||
id: string
|
||||
title: string
|
||||
titleNumber: number
|
||||
rows?: number
|
||||
cols?: number
|
||||
buffer?: string
|
||||
@@ -42,8 +43,20 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
|
||||
all: createMemo(() => Object.values(store.all)),
|
||||
active: createMemo(() => store.active),
|
||||
new() {
|
||||
const existingTitleNumbers = new Set(
|
||||
store.all.map((pty) => {
|
||||
const match = pty.titleNumber
|
||||
return match
|
||||
}),
|
||||
)
|
||||
|
||||
let nextNumber = 1
|
||||
while (existingTitleNumbers.has(nextNumber)) {
|
||||
nextNumber++
|
||||
}
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: `Terminal ${store.all.length + 1}` })
|
||||
.create({ title: `Terminal ${nextNumber}` })
|
||||
.then((pty) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
@@ -52,6 +65,7 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
|
||||
{
|
||||
id,
|
||||
title: pty.data?.title ?? "Terminal",
|
||||
titleNumber: nextNumber,
|
||||
},
|
||||
])
|
||||
setStore("active", id)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useSession } from "@solidjs/start/http"
|
||||
|
||||
export interface AuthSession {
|
||||
account?: Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
>
|
||||
current?: string
|
||||
}
|
||||
|
||||
export function useAuthSession() {
|
||||
return useSession<AuthSession>({
|
||||
password: "0".repeat(32),
|
||||
name: "auth",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
cookie: {
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,13 +5,38 @@ import { redirect } from "@solidjs/router"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
|
||||
import { createClient } from "@openauthjs/openauth/client"
|
||||
import { useAuthSession } from "./auth.session"
|
||||
|
||||
export const AuthClient = createClient({
|
||||
clientID: "app",
|
||||
issuer: import.meta.env.VITE_AUTH_URL,
|
||||
})
|
||||
|
||||
import { useSession } from "@solidjs/start/http"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export interface AuthSession {
|
||||
account?: Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
>
|
||||
current?: string
|
||||
}
|
||||
|
||||
export function useAuthSession() {
|
||||
return useSession<AuthSession>({
|
||||
password: Resource.ZEN_SESSION_SECRET.value,
|
||||
name: "auth",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
cookie: {
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const getActor = async (workspace?: string): Promise<Actor.Info> => {
|
||||
"use server"
|
||||
const evt = getRequestEvent()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "@solidjs/router"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
import { useAuthSession } from "~/context/auth.session"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "@solidjs/router"
|
||||
import { APIEvent } from "@solidjs/start"
|
||||
import { useAuthSession } from "~/context/auth.session"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
|
||||
export async function GET(event: APIEvent) {
|
||||
const auth = await useAuthSession()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APIEvent } from "@solidjs/start"
|
||||
import { useAuthSession } from "~/context/auth.session"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const session = await useAuthSession()
|
||||
|
||||
@@ -131,6 +131,188 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-slot="pricing"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
padding: 0 20px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="pricing-card"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
}
|
||||
|
||||
[data-slot="price"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="amount"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-slot="period"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-slot="multiplier"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-size: 14px;
|
||||
|
||||
&::before {
|
||||
content: "·";
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="selected-plan"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 40px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
[data-slot="selected-card"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
|
||||
[data-slot="icon"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
}
|
||||
|
||||
[data-slot="price"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="amount"] {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-slot="period"] {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-slot="multiplier"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
font-size: 14px;
|
||||
|
||||
&::before {
|
||||
content: "·";
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="terms"] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
|
||||
li {
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
content: "▪";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="actions"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
|
||||
button,
|
||||
a {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-slot="cancel"] {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="continue"] {
|
||||
background: rgba(255, 255, 255, 0.17);
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
color: rgba(255, 255, 255, 0.59);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="fine-print"] {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
text-align: center;
|
||||
@@ -138,6 +320,12 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 160%; /* 20.8px */
|
||||
font-style: italic;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.39);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,54 @@
|
||||
import { A, createAsync } from "@solidjs/router"
|
||||
import { A, createAsync, useSearchParams } from "@solidjs/router"
|
||||
import "./index.css"
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { github } from "~/lib/github"
|
||||
import { createMemo, Match, Switch } from "solid-js"
|
||||
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { config } from "~/config"
|
||||
|
||||
const plans = [
|
||||
{ id: "20", amount: 20, multiplier: null },
|
||||
{ id: "100", amount: 100, multiplier: "6x more usage than Black 20" },
|
||||
{ id: "200", amount: 200, multiplier: "21x more usage than Black 20" },
|
||||
] as const
|
||||
|
||||
function PlanIcon(props: { plan: string }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.plan === "20"}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={props.plan === "100"}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</Match>
|
||||
<Match when={props.plan === "200"}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="10" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="18" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="2" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="10" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="18" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="2" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="10" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="18" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
|
||||
</svg>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Black() {
|
||||
const [params] = useSearchParams()
|
||||
const [selected, setSelected] = createSignal<string | null>(params.plan as string | null)
|
||||
const selectedPlan = createMemo(() => plans.find((p) => p.id === selected()))
|
||||
|
||||
const githubData = createAsync(() => github())
|
||||
const starCount = createMemo(() =>
|
||||
githubData()?.stars
|
||||
@@ -16,9 +59,6 @@ export default function Black() {
|
||||
: config.github.starsFormatted.compact,
|
||||
)
|
||||
|
||||
// TODO: Frank, toggle this based on availability
|
||||
const available = false
|
||||
|
||||
return (
|
||||
<div data-page="black">
|
||||
<Title>opencode</Title>
|
||||
@@ -148,17 +188,65 @@ export default function Black() {
|
||||
<p data-slot="subheading">Including Claude, GPT, Gemini, and more</p>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={available}>
|
||||
<a href="/black/subscribe" data-slot="button">
|
||||
Subscribe $200/mo
|
||||
</a>
|
||||
<p data-slot="fine-print">Fair usage limits apply</p>
|
||||
<Match when={!selected()}>
|
||||
<div data-slot="pricing">
|
||||
<For each={plans}>
|
||||
{(plan) => (
|
||||
<button type="button" onClick={() => setSelected(plan.id)} data-slot="pricing-card">
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan.id} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${plan.amount}</span> <span data-slot="period">per month</span>
|
||||
<Show when={plan.multiplier}>
|
||||
<span data-slot="multiplier">{plan.multiplier}</span>
|
||||
</Show>
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p data-slot="fine-print">
|
||||
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
|
||||
</p>
|
||||
</Match>
|
||||
<Match when={!available}>
|
||||
<p data-slot="back-soon">We’ll be back soon with more availability.</p>
|
||||
<a data-slot="follow-us" href="https://x.com/opencode" target="_blank">
|
||||
Follow @opencode
|
||||
</a>
|
||||
<Match when={selectedPlan()}>
|
||||
{(plan) => (
|
||||
<div data-slot="selected-plan">
|
||||
<div data-slot="selected-card">
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan().id} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${plan().amount}</span>{" "}
|
||||
<span data-slot="period">per person billed monthly</span>
|
||||
<Show when={plan().multiplier}>
|
||||
<span data-slot="multiplier">{plan().multiplier}</span>
|
||||
</Show>
|
||||
</p>
|
||||
<ul data-slot="terms">
|
||||
<li>Your subscription will not start immediately</li>
|
||||
<li>You will be added to the waitlist and activated soon</li>
|
||||
<li>Your card will be only charged when your subscription is activated</li>
|
||||
<li>Usage limits apply, heavily automated use may reach limits sooner</li>
|
||||
<li>Subscriptions for individuals, contact Enterprise for teams</li>
|
||||
<li>Limits may be adjusted and plans may be discontinued in the future</li>
|
||||
<li>Cancel your subscription at anytime</li>
|
||||
</ul>
|
||||
<div data-slot="actions">
|
||||
<button type="button" onClick={() => setSelected(null)} data-slot="cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<a href={`/black/subscribe?plan=${plan().id}`} data-slot="continue">
|
||||
Continue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p data-slot="fine-print">
|
||||
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { action } from "@solidjs/router"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { useAuthSession } from "~/context/auth.session"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
import { Dropdown } from "~/component/dropdown"
|
||||
import "./user-menu.css"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Vendored
+4
@@ -130,6 +130,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+4
@@ -130,6 +130,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
+4
@@ -130,6 +130,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { $ } from "bun"
|
||||
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
|
||||
|
||||
const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
|
||||
|
||||
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
|
||||
|
||||
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
|
||||
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
|
||||
|
||||
await $`cd ../opencode && bun run build --single`
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
|
||||
|
||||
const sidecarConfig = getCurrentSidecar()
|
||||
|
||||
@@ -10,6 +10,4 @@ const dir = "src-tauri/target/opencode-binaries"
|
||||
await $`mkdir -p ${dir}`
|
||||
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
|
||||
|
||||
await copyBinaryToSidecarFolder(
|
||||
`${dir}/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`,
|
||||
)
|
||||
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
|
||||
|
||||
@@ -41,8 +41,13 @@ export function getCurrentSidecar(target = RUST_TARGET) {
|
||||
|
||||
export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) {
|
||||
await $`mkdir -p src-tauri/sidecars`
|
||||
const dest = `src-tauri/sidecars/opencode-cli-${target}${process.platform === "win32" ? ".exe" : ""}`
|
||||
const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
|
||||
await $`cp ${source} ${dest}`
|
||||
|
||||
console.log(`Copied ${source} to ${dest}`)
|
||||
}
|
||||
|
||||
export function windowsify(path: string) {
|
||||
if (path.endsWith(".exe")) return path
|
||||
return `${path}${process.platform === "win32" ? ".exe" : ""}`
|
||||
}
|
||||
|
||||
Generated
+4
-3
@@ -2814,6 +2814,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
]
|
||||
|
||||
@@ -5364,13 +5365,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
|
||||
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ tauri-plugin-os = "2"
|
||||
futures = "0.3.31"
|
||||
semver = "1.0.27"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
|
||||
@@ -155,6 +155,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
|
||||
.shell()
|
||||
.sidecar("opencode-cli")
|
||||
.unwrap()
|
||||
.args(args.split_whitespace())
|
||||
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
||||
.env("OPENCODE_CLIENT", "desktop")
|
||||
.env("XDG_STATE_HOME", &state_dir);
|
||||
|
||||
@@ -3,6 +3,7 @@ mod window_customizer;
|
||||
|
||||
use cli::{install_cli, sync_cli};
|
||||
use futures::FutureExt;
|
||||
use futures::future;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::TcpListener,
|
||||
@@ -13,22 +14,29 @@ use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, Webvie
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||
|
||||
const SETTINGS_STORE: &str = "opencode.settings.dat";
|
||||
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct ServerReadyData {
|
||||
url: String,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServerState {
|
||||
child: Arc<Mutex<Option<CommandChild>>>,
|
||||
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
|
||||
status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(
|
||||
child: Option<CommandChild>,
|
||||
status: tokio::sync::oneshot::Receiver<Result<String, String>>,
|
||||
status: oneshot::Receiver<Result<ServerReadyData, String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
@@ -80,7 +88,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
|
||||
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
|
||||
state
|
||||
.status
|
||||
.clone()
|
||||
@@ -137,13 +145,14 @@ fn get_sidecar_port() -> u32 {
|
||||
}) as u32
|
||||
}
|
||||
|
||||
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild {
|
||||
let log_state = app.state::<LogState>();
|
||||
let log_state_clone = log_state.inner().clone();
|
||||
|
||||
println!("spawning sidecar on port {port}");
|
||||
|
||||
let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
|
||||
.env("OPENCODE_SERVER_PASSWORD", password)
|
||||
.spawn()
|
||||
.expect("Failed to spawn opencode");
|
||||
|
||||
@@ -184,7 +193,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
child
|
||||
}
|
||||
|
||||
async fn check_server_health(url: &str) -> bool {
|
||||
async fn check_server_health(url: &str, password: Option<&str>) -> bool {
|
||||
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
@@ -194,9 +203,13 @@ async fn check_server_health(url: &str) -> bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
client
|
||||
.get(&health_url)
|
||||
.send()
|
||||
let mut req = client.get(&health_url);
|
||||
|
||||
if let Some(password) = password {
|
||||
req = req.basic_auth("opencode", Some(password));
|
||||
}
|
||||
|
||||
req.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
@@ -267,7 +280,7 @@ pub fn run() {
|
||||
|
||||
window_builder.build().expect("Failed to create window");
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
app.manage(ServerState::new(None, rx));
|
||||
|
||||
{
|
||||
@@ -344,12 +357,18 @@ fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
|
||||
async fn setup_server_connection(
|
||||
app: &AppHandle,
|
||||
custom_url: Option<String>,
|
||||
) -> Result<(Option<CommandChild>, String), String> {
|
||||
) -> Result<(Option<CommandChild>, ServerReadyData), String> {
|
||||
if let Some(url) = custom_url {
|
||||
loop {
|
||||
if check_server_health(&url).await {
|
||||
if check_server_health(&url, None).await {
|
||||
println!("Connected to custom server: {}", url);
|
||||
return Ok((None, url.clone()));
|
||||
return Ok((
|
||||
None,
|
||||
ServerReadyData {
|
||||
url: url.clone(),
|
||||
password: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
const RETRY: &str = "Retry";
|
||||
@@ -374,19 +393,36 @@ async fn setup_server_connection(
|
||||
let local_port = get_sidecar_port();
|
||||
let local_url = format!("http://127.0.0.1:{local_port}");
|
||||
|
||||
if !check_server_health(&local_url).await {
|
||||
match spawn_local_server(app, local_port).await {
|
||||
Ok(child) => Ok(Some(child)),
|
||||
if !check_server_health(&local_url, None).await {
|
||||
let password = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
match spawn_local_server(app, local_port, &password).await {
|
||||
Ok(child) => Ok((
|
||||
Some(child),
|
||||
ServerReadyData {
|
||||
url: local_url,
|
||||
password: Some(password),
|
||||
},
|
||||
)),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
Ok((
|
||||
None,
|
||||
ServerReadyData {
|
||||
url: local_url,
|
||||
password: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
.map(|child| (child, local_url))
|
||||
}
|
||||
|
||||
async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
|
||||
let child = spawn_sidecar(app, port);
|
||||
async fn spawn_local_server(
|
||||
app: &AppHandle,
|
||||
port: u32,
|
||||
password: &str,
|
||||
) -> Result<CommandChild, String> {
|
||||
let child = spawn_sidecar(app, port, password);
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let timestamp = Instant::now();
|
||||
@@ -400,7 +436,7 @@ async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild,
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
if check_server_health(&url).await {
|
||||
if check_server_health(&url, Some(password)).await {
|
||||
println!("Server ready after {:?}", timestamp.elapsed());
|
||||
break Ok(child);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,11 @@ import { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Accessor, JSX, createResource } from "solid-js"
|
||||
import { createSignal, Show, Accessor, JSX, createResource } from "solid-js"
|
||||
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { createMenu } from "./menu"
|
||||
import pkg from "../package.json"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -29,7 +28,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
|
||||
let update: Update | null = null
|
||||
|
||||
const platform: Platform = {
|
||||
const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
platform: "desktop",
|
||||
version: pkg.version,
|
||||
|
||||
@@ -256,7 +255,25 @@ const platform: Platform = {
|
||||
},
|
||||
|
||||
// @ts-expect-error
|
||||
fetch: tauriFetch,
|
||||
fetch: (input, init) => {
|
||||
const pw = password()
|
||||
|
||||
const addHeader = (headers: Headers, password: string) => {
|
||||
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
|
||||
}
|
||||
|
||||
if (input instanceof Request) {
|
||||
if (pw) addHeader(input.headers, pw)
|
||||
return tauriFetch(input)
|
||||
} else {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (pw) addHeader(headers, pw)
|
||||
return tauriFetch(input, {
|
||||
...(init as any),
|
||||
headers: headers,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
getDefaultServerUrl: async () => {
|
||||
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
|
||||
@@ -266,7 +283,7 @@ const platform: Platform = {
|
||||
setDefaultServerUrl: async (url: string | null) => {
|
||||
await invoke("set_default_server_url", { url })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
createMenu()
|
||||
|
||||
@@ -276,26 +293,37 @@ root?.addEventListener("mousewheel", (e) => {
|
||||
})
|
||||
|
||||
render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
{ostype() === "macos" && (
|
||||
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
|
||||
)}
|
||||
<AppBaseProviders>
|
||||
<ServerGate>{(serverUrl) => <AppInterface defaultUrl={serverUrl()} />}</ServerGate>
|
||||
{ostype() === "macos" && (
|
||||
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
|
||||
)}
|
||||
<ServerGate>
|
||||
{(data) => {
|
||||
setServerPassword(data().password)
|
||||
|
||||
return <AppInterface defaultUrl={data().url} />
|
||||
}}
|
||||
</ServerGate>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}, root!)
|
||||
|
||||
type ServerReadyData = { url: string; password: string | null }
|
||||
|
||||
// Gate component that waits for the server to be ready
|
||||
function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
|
||||
const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
|
||||
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
|
||||
const [serverData] = createResource<ServerReadyData>(() => invoke("ensure_server_ready"))
|
||||
|
||||
return (
|
||||
// Not using suspense as not all components are compatible with it (undefined refs)
|
||||
<Show
|
||||
when={serverUrl.state !== "pending" && serverUrl()}
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Logo class="w-xl opacity-12 animate-pulse" />
|
||||
@@ -303,7 +331,7 @@ function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element })
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(serverUrl) => props.children(serverUrl)}
|
||||
{(data) => props.children(data)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Vendored
+4
@@ -130,6 +130,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.15"
|
||||
version = "1.1.16"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.15/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Vendored
+4
@@ -130,6 +130,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -50,13 +50,14 @@ export namespace Agent {
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
[Truncate.DIR]: "allow",
|
||||
[Truncate.GLOB]: "allow",
|
||||
},
|
||||
question: "deny",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
"*.env": "deny",
|
||||
"*.env.*": "deny",
|
||||
"*.env": "ask",
|
||||
"*.env.*": "ask",
|
||||
"*.env.example": "allow",
|
||||
},
|
||||
})
|
||||
@@ -124,6 +125,7 @@ export namespace Agent {
|
||||
read: "allow",
|
||||
external_directory: {
|
||||
[Truncate.DIR]: "allow",
|
||||
[Truncate.GLOB]: "allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
@@ -213,14 +215,16 @@ export namespace Agent {
|
||||
// Ensure Truncate.DIR is allowed unless explicitly configured
|
||||
for (const name in result) {
|
||||
const agent = result[name]
|
||||
const explicit = agent.permission.some(
|
||||
(r) => r.permission === "external_directory" && r.pattern === Truncate.DIR && r.action === "deny",
|
||||
)
|
||||
const explicit = agent.permission.some((r) => {
|
||||
if (r.permission !== "external_directory") return false
|
||||
if (r.action !== "deny") return false
|
||||
return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB
|
||||
})
|
||||
if (explicit) continue
|
||||
|
||||
result[name].permission = PermissionNext.merge(
|
||||
result[name].permission,
|
||||
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow" } }),
|
||||
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { EOL } from "os"
|
||||
import { basename } from "path"
|
||||
import { Agent } from "../../../agent/agent"
|
||||
import { Provider } from "../../../provider/provider"
|
||||
import { Session } from "../../../session"
|
||||
import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { Identifier } from "../../../id/id"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { PermissionNext } from "../../../permission/next"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
@@ -8,11 +16,20 @@ export const AgentCommand = cmd({
|
||||
command: "agent <name>",
|
||||
describe: "show agent configuration details",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("name", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Agent name",
|
||||
}),
|
||||
yargs
|
||||
.positional("name", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
description: "Agent name",
|
||||
})
|
||||
.option("tool", {
|
||||
type: "string",
|
||||
description: "Tool id to execute",
|
||||
})
|
||||
.option("params", {
|
||||
type: "string",
|
||||
description: "Tool params as JSON or a JS object literal",
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const agentName = args.name as string
|
||||
@@ -23,7 +40,127 @@ export const AgentCommand = cmd({
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
|
||||
const availableTools = await getAvailableTools(agent)
|
||||
const resolvedTools = await resolveTools(agent, availableTools)
|
||||
const toolID = args.tool as string | undefined
|
||||
if (toolID) {
|
||||
const tool = availableTools.find((item) => item.id === toolID)
|
||||
if (!tool) {
|
||||
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
|
||||
process.exit(1)
|
||||
}
|
||||
if (resolvedTools[toolID] === false) {
|
||||
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
|
||||
process.exit(1)
|
||||
}
|
||||
const params = parseToolParams(args.params as string | undefined)
|
||||
const ctx = await createToolContext(agent)
|
||||
const result = await tool.execute(params, ctx)
|
||||
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const output = {
|
||||
...agent,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
|
||||
return ToolRegistry.tools(providerID, agent)
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
const disabled = PermissionNext.disabled(
|
||||
availableTools.map((tool) => tool.id),
|
||||
agent.permission,
|
||||
)
|
||||
const resolved: Record<string, boolean> = {}
|
||||
for (const tool of availableTools) {
|
||||
resolved[tool.id] = !disabled.has(tool.id)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function parseToolParams(input?: string) {
|
||||
if (!input) return {}
|
||||
const trimmed = input.trim()
|
||||
if (trimmed.length === 0) return {}
|
||||
|
||||
const parsed = iife(() => {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch (jsonError) {
|
||||
try {
|
||||
return new Function(`return (${trimmed})`)()
|
||||
} catch (evalError) {
|
||||
throw new Error(
|
||||
`Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Tool params must be an object.")
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
async function createToolContext(agent: Agent.Info) {
|
||||
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = Identifier.ascending("message")
|
||||
const model = agent.model ?? (await Provider.defaultModel())
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
|
||||
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
return {
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
callID: Identifier.ascending("part"),
|
||||
agent: agent.name,
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new PermissionNext.DeniedError(ruleset)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ const FileTreeCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
|
||||
console.log(files)
|
||||
console.log(JSON.stringify(files, null, 2))
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -624,7 +624,7 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
} catch (e: any) {
|
||||
exitCode = 1
|
||||
console.error(e)
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
let msg = e
|
||||
if (e instanceof $.ShellError) {
|
||||
msg = e.stderr.toString()
|
||||
@@ -915,7 +915,7 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
// result should always be assistant just satisfying type checker
|
||||
if (result.info.role === "assistant" && result.info.error) {
|
||||
console.error(result.info)
|
||||
console.error("Agent error:", result.info.error)
|
||||
throw new Error(
|
||||
`${result.info.error.name}: ${"message" in result.info.error ? result.info.error.message : ""}`,
|
||||
)
|
||||
@@ -944,7 +944,7 @@ export const GithubRunCommand = cmd({
|
||||
})
|
||||
|
||||
if (summary.info.role === "assistant" && summary.info.error) {
|
||||
console.error(summary.info)
|
||||
console.error("Summary agent error:", summary.info.error)
|
||||
throw new Error(
|
||||
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
|
||||
)
|
||||
@@ -962,7 +962,7 @@ export const GithubRunCommand = cmd({
|
||||
try {
|
||||
return await core.getIDToken("opencode-github-action")
|
||||
} catch (error) {
|
||||
console.error("Failed to get OIDC token:", error)
|
||||
console.error("Failed to get OIDC token:", error instanceof Error ? error.message : error)
|
||||
throw new Error(
|
||||
"Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions.",
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
@@ -13,6 +12,7 @@ import { Instance } from "../../project/instance"
|
||||
import { Installation } from "../../installation"
|
||||
import path from "path"
|
||||
import { Global } from "../../global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
switch (status) {
|
||||
@@ -366,133 +366,204 @@ export const McpLogoutCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
async function resolveConfigPath(baseDir: string, global = false) {
|
||||
// Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
|
||||
const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
|
||||
|
||||
if (!global) {
|
||||
candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await Bun.file(candidate).exists()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Default to opencode.json if none exist
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
|
||||
const file = Bun.file(configPath)
|
||||
|
||||
let text = "{}"
|
||||
if (await file.exists()) {
|
||||
text = await file.text()
|
||||
}
|
||||
|
||||
// Use jsonc-parser to modify while preserving comments
|
||||
const edits = modify(text, ["mcp", name], mcpConfig, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
const result = applyEdits(text, edits)
|
||||
|
||||
await Bun.write(configPath, result)
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
export const McpAddCommand = cmd({
|
||||
command: "add",
|
||||
describe: "add an MCP server",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
prompts.intro("Add MCP server")
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
UI.empty()
|
||||
prompts.intro("Add MCP server")
|
||||
|
||||
const name = await prompts.text({
|
||||
message: "Enter MCP server name",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||
const project = Instance.project
|
||||
|
||||
const type = await prompts.select({
|
||||
message: "Select MCP server type",
|
||||
options: [
|
||||
{
|
||||
label: "Local",
|
||||
value: "local",
|
||||
hint: "Run a local command",
|
||||
},
|
||||
{
|
||||
label: "Remote",
|
||||
value: "remote",
|
||||
hint: "Connect to a remote URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
||||
// Resolve config paths eagerly for hints
|
||||
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||
resolveConfigPath(Instance.worktree),
|
||||
resolveConfigPath(Global.Path.config, true),
|
||||
])
|
||||
|
||||
if (type === "local") {
|
||||
const command = await prompts.text({
|
||||
message: "Enter command to run",
|
||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
// Determine scope
|
||||
let configPath = globalConfigPath
|
||||
if (project.vcs === "git") {
|
||||
const scopeResult = await prompts.select({
|
||||
message: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Current project",
|
||||
value: projectConfigPath,
|
||||
hint: projectConfigPath,
|
||||
},
|
||||
{
|
||||
label: "Global",
|
||||
value: globalConfigPath,
|
||||
hint: globalConfigPath,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||
configPath = scopeResult
|
||||
}
|
||||
|
||||
prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
|
||||
prompts.outro("MCP server added successfully")
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "remote") {
|
||||
const url = await prompts.text({
|
||||
message: "Enter MCP server URL",
|
||||
placeholder: "e.g., https://example.com/mcp",
|
||||
validate: (x) => {
|
||||
if (!x) return "Required"
|
||||
if (x.length === 0) return "Required"
|
||||
const isValid = URL.canParse(x)
|
||||
return isValid ? undefined : "Invalid URL"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
||||
|
||||
const useOAuth = await prompts.confirm({
|
||||
message: "Does this server require OAuth authentication?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||
|
||||
if (useOAuth) {
|
||||
const hasClientId = await prompts.confirm({
|
||||
message: "Do you have a pre-registered client ID?",
|
||||
initialValue: false,
|
||||
const name = await prompts.text({
|
||||
message: "Enter MCP server name",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
|
||||
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||
|
||||
if (hasClientId) {
|
||||
const clientId = await prompts.text({
|
||||
message: "Enter client ID",
|
||||
const type = await prompts.select({
|
||||
message: "Select MCP server type",
|
||||
options: [
|
||||
{
|
||||
label: "Local",
|
||||
value: "local",
|
||||
hint: "Run a local command",
|
||||
},
|
||||
{
|
||||
label: "Remote",
|
||||
value: "remote",
|
||||
hint: "Connect to a remote URL",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(type)) throw new UI.CancelledError()
|
||||
|
||||
if (type === "local") {
|
||||
const command = await prompts.text({
|
||||
message: "Enter command to run",
|
||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
|
||||
const hasSecret = await prompts.confirm({
|
||||
message: "Do you have a client secret?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
|
||||
|
||||
let clientSecret: string | undefined
|
||||
if (hasSecret) {
|
||||
const secret = await prompts.password({
|
||||
message: "Enter client secret",
|
||||
})
|
||||
if (prompts.isCancel(secret)) throw new UI.CancelledError()
|
||||
clientSecret = secret
|
||||
const mcpConfig: Config.Mcp = {
|
||||
type: "local",
|
||||
command: command.split(" "),
|
||||
}
|
||||
|
||||
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
|
||||
prompts.log.info("Add this to your opencode.json:")
|
||||
prompts.log.info(`
|
||||
"mcp": {
|
||||
"${name}": {
|
||||
"type": "remote",
|
||||
"url": "${url}",
|
||||
"oauth": {
|
||||
"clientId": "${clientId}"${clientSecret ? `,\n "clientSecret": "${clientSecret}"` : ""}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
} else {
|
||||
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`)
|
||||
prompts.log.info("Add this to your opencode.json:")
|
||||
prompts.log.info(`
|
||||
"mcp": {
|
||||
"${name}": {
|
||||
"type": "remote",
|
||||
"url": "${url}",
|
||||
"oauth": {}
|
||||
}
|
||||
}`)
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
prompts.outro("MCP server added successfully")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const client = new Client({
|
||||
name: "opencode",
|
||||
version: "1.0.0",
|
||||
})
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url))
|
||||
await client.connect(transport)
|
||||
prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
|
||||
}
|
||||
}
|
||||
|
||||
prompts.outro("MCP server added successfully")
|
||||
if (type === "remote") {
|
||||
const url = await prompts.text({
|
||||
message: "Enter MCP server URL",
|
||||
placeholder: "e.g., https://example.com/mcp",
|
||||
validate: (x) => {
|
||||
if (!x) return "Required"
|
||||
if (x.length === 0) return "Required"
|
||||
const isValid = URL.canParse(x)
|
||||
return isValid ? undefined : "Invalid URL"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(url)) throw new UI.CancelledError()
|
||||
|
||||
const useOAuth = await prompts.confirm({
|
||||
message: "Does this server require OAuth authentication?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||
|
||||
let mcpConfig: Config.Mcp
|
||||
|
||||
if (useOAuth) {
|
||||
const hasClientId = await prompts.confirm({
|
||||
message: "Do you have a pre-registered client ID?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
|
||||
|
||||
if (hasClientId) {
|
||||
const clientId = await prompts.text({
|
||||
message: "Enter client ID",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
||||
|
||||
const hasSecret = await prompts.confirm({
|
||||
message: "Do you have a client secret?",
|
||||
initialValue: false,
|
||||
})
|
||||
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
|
||||
|
||||
let clientSecret: string | undefined
|
||||
if (hasSecret) {
|
||||
const secret = await prompts.password({
|
||||
message: "Enter client secret",
|
||||
})
|
||||
if (prompts.isCancel(secret)) throw new UI.CancelledError()
|
||||
clientSecret = secret
|
||||
}
|
||||
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {
|
||||
clientId,
|
||||
...(clientSecret && { clientSecret }),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
oauth: {},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mcpConfig = {
|
||||
type: "remote",
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
await addMcpToConfig(name, mcpConfig, configPath)
|
||||
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||
}
|
||||
|
||||
prompts.outro("MCP server added successfully")
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
}
|
||||
onConfirm={async (value) => {
|
||||
if (!value) return
|
||||
sdk.client.auth.set({
|
||||
await sdk.client.auth.set({
|
||||
providerID: props.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
|
||||
@@ -158,7 +158,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (evt.name === "return") {
|
||||
const option = selected()
|
||||
if (option) {
|
||||
// evt.preventDefault()
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
if (option.onSelect) option.onSelect(dialog)
|
||||
props.onSelect?.(option)
|
||||
}
|
||||
|
||||
@@ -209,6 +209,19 @@ export namespace Config {
|
||||
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
for (const pattern of patterns) {
|
||||
const index = item.indexOf(pattern)
|
||||
if (index === -1) continue
|
||||
return item.slice(index + pattern.length)
|
||||
}
|
||||
}
|
||||
|
||||
function trim(file: string) {
|
||||
const ext = path.extname(file)
|
||||
return ext.length ? file.slice(0, -ext.length) : file
|
||||
}
|
||||
|
||||
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
|
||||
async function loadCommand(dir: string) {
|
||||
const result: Record<string, Command> = {}
|
||||
@@ -221,16 +234,9 @@ export namespace Config {
|
||||
const md = await ConfigMarkdown.parse(item)
|
||||
if (!md.data) continue
|
||||
|
||||
const name = (() => {
|
||||
const patterns = ["/.opencode/command/", "/command/"]
|
||||
const pattern = patterns.find((p) => item.includes(p))
|
||||
|
||||
if (pattern) {
|
||||
const index = item.indexOf(pattern)
|
||||
return item.slice(index + pattern.length, -3)
|
||||
}
|
||||
return path.basename(item, ".md")
|
||||
})()
|
||||
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
|
||||
const file = rel(item, patterns) ?? path.basename(item)
|
||||
const name = trim(file)
|
||||
|
||||
const config = {
|
||||
name,
|
||||
@@ -260,20 +266,9 @@ export namespace Config {
|
||||
const md = await ConfigMarkdown.parse(item)
|
||||
if (!md.data) continue
|
||||
|
||||
// Extract relative path from agent folder for nested agents
|
||||
let agentName = path.basename(item, ".md")
|
||||
const agentFolderPath = item.includes("/.opencode/agent/")
|
||||
? item.split("/.opencode/agent/")[1]
|
||||
: item.includes("/agent/")
|
||||
? item.split("/agent/")[1]
|
||||
: agentName + ".md"
|
||||
|
||||
// If agent is in a subfolder, include folder path in name
|
||||
if (agentFolderPath.includes("/")) {
|
||||
const relativePath = agentFolderPath.replace(".md", "")
|
||||
const pathParts = relativePath.split("/")
|
||||
agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1]
|
||||
}
|
||||
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
|
||||
const file = rel(item, patterns) ?? path.basename(item)
|
||||
const agentName = trim(file)
|
||||
|
||||
const config = {
|
||||
name: agentName,
|
||||
@@ -1164,7 +1159,7 @@ export namespace Config {
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2))
|
||||
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {})
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
|
||||
@@ -96,7 +96,7 @@ export const oxfmt: Info = {
|
||||
|
||||
export const biome: Info = {
|
||||
name: "biome",
|
||||
command: [BunProc.which(), "x", "@biomejs/biome", "format", "--write", "$FILE"],
|
||||
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
|
||||
@@ -147,7 +147,7 @@ try {
|
||||
if (formatted) UI.error(formatted)
|
||||
if (formatted === undefined) {
|
||||
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
|
||||
console.error(e)
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
|
||||
@@ -237,6 +237,7 @@ export namespace Provider {
|
||||
"nova-lite",
|
||||
"nova-pro",
|
||||
"nova-premier",
|
||||
"nova-2",
|
||||
"claude",
|
||||
"deepseek",
|
||||
].some((m) => modelID.includes(m))
|
||||
@@ -418,11 +419,26 @@ export namespace Provider {
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
},
|
||||
// Custom fetch to strip Authorization header - AI Gateway uses cf-aig-authorization instead
|
||||
// Sending Authorization header with invalid value causes auth errors
|
||||
// Custom fetch to handle parameter transformation and auth
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
// Strip Authorization header - AI Gateway uses cf-aig-authorization instead
|
||||
headers.delete("Authorization")
|
||||
|
||||
// Transform max_tokens to max_completion_tokens for newer models
|
||||
if (init?.body && init.method === "POST") {
|
||||
try {
|
||||
const body = JSON.parse(init.body as string)
|
||||
if (body.max_tokens !== undefined && !body.max_completion_tokens) {
|
||||
body.max_completion_tokens = body.max_tokens
|
||||
delete body.max_tokens
|
||||
init = { ...init, body: JSON.stringify(body) }
|
||||
}
|
||||
} catch (e) {
|
||||
// If body parsing fails, continue with original request
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(input, { ...init, headers })
|
||||
},
|
||||
},
|
||||
|
||||
@@ -364,6 +364,25 @@ export namespace ProviderTransform {
|
||||
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
|
||||
// For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
|
||||
if (model.api.id.includes("anthropic")) {
|
||||
return {
|
||||
high: {
|
||||
reasoningConfig: {
|
||||
type: "enabled",
|
||||
budgetTokens: 16000,
|
||||
},
|
||||
},
|
||||
max: {
|
||||
reasoningConfig: {
|
||||
type: "enabled",
|
||||
budgetTokens: 31999,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// For Amazon Nova models, use reasoningConfig with maxReasoningEffort
|
||||
return Object.fromEntries(
|
||||
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
|
||||
effort,
|
||||
@@ -457,6 +476,13 @@ export namespace ProviderTransform {
|
||||
result["chat_template_args"] = { enable_thinking: true }
|
||||
}
|
||||
|
||||
if (["zai", "zhipuai"].includes(model.providerID) && model.api.npm === "@ai-sdk/openai-compatible") {
|
||||
result["thinking"] = {
|
||||
type: "enabled",
|
||||
clear_thinking: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (model.providerID === "openai" || providerOptions?.setCacheKey) {
|
||||
result["promptCacheKey"] = sessionID
|
||||
}
|
||||
|
||||
@@ -664,6 +664,7 @@ export namespace MessageV2 {
|
||||
return `${msg}: ${e.responseBody}`
|
||||
}).trim()
|
||||
|
||||
const metadata = e.url ? { url: e.url } : undefined
|
||||
return new MessageV2.APIError(
|
||||
{
|
||||
message,
|
||||
@@ -671,6 +672,7 @@ export namespace MessageV2 {
|
||||
isRetryable: e.isRetryable,
|
||||
responseHeaders: e.responseHeaders,
|
||||
responseBody: e.responseBody,
|
||||
metadata,
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
|
||||
@@ -119,7 +119,7 @@ export const EditTool = Tool.define("edit", {
|
||||
},
|
||||
})
|
||||
|
||||
let output = ""
|
||||
let output = "Edit applied successfully."
|
||||
await LSP.touchFile(filePath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
@@ -129,7 +129,7 @@ export const EditTool = Tool.define("edit", {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
|
||||
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -31,7 +31,7 @@ export namespace ToolRegistry {
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const custom = [] as Tool.Info[]
|
||||
const glob = new Bun.Glob("tool/*.{js,ts}")
|
||||
const glob = new Bun.Glob("{tool,tools}/*.{js,ts}")
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
for await (const match of glob.scan({
|
||||
|
||||
@@ -56,6 +56,9 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
|
||||
const agent = await Agent.get(params.subagent_type)
|
||||
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
|
||||
|
||||
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
|
||||
|
||||
const session = await iife(async () => {
|
||||
if (params.session_id) {
|
||||
const found = await Session.get(params.session_id).catch(() => {})
|
||||
@@ -76,11 +79,15 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
},
|
||||
{
|
||||
permission: "task",
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
},
|
||||
...(hasTaskPermission
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "task" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(config.experimental?.primary_tools?.map((t) => ({
|
||||
pattern: "*",
|
||||
action: "allow" as const,
|
||||
@@ -146,7 +153,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
tools: {
|
||||
todowrite: false,
|
||||
todoread: false,
|
||||
task: false,
|
||||
...(hasTaskPermission ? {} : { task: false }),
|
||||
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
|
||||
},
|
||||
parts: promptParts,
|
||||
|
||||
@@ -10,6 +10,7 @@ export namespace Truncate {
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const DIR = path.join(Global.Path.data, "tool-output")
|
||||
export const GLOB = path.join(DIR, "*")
|
||||
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
|
||||
@@ -47,7 +47,7 @@ export const WriteTool = Tool.define("write", {
|
||||
})
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = ""
|
||||
let output = "Wrote file successfully."
|
||||
await LSP.touchFile(filepath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const normalizedFilepath = Filesystem.normalizePath(filepath)
|
||||
@@ -59,12 +59,12 @@ export const WriteTool = Tool.define("write", {
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
if (file === normalizedFilepath) {
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
|
||||
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
continue
|
||||
}
|
||||
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
||||
projectDiagnosticsCount++
|
||||
output += `\n<project_diagnostics>\n${file}\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</project_diagnostics>\n`
|
||||
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -461,6 +461,7 @@ test("Truncate.DIR is allowed even when user denies external_directory globally"
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
@@ -484,6 +485,7 @@ test("Truncate.DIR is allowed even when user denies external_directory per-agent
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
@@ -506,6 +508,7 @@ test("explicit Truncate.DIR deny is respected", async () => {
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -334,6 +334,147 @@ Test agent prompt`,
|
||||
})
|
||||
})
|
||||
|
||||
test("loads agents from .opencode/agents (plural)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
const agentsDir = path.join(opencodeDir, "agents")
|
||||
await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(agentsDir, "helper.md"),
|
||||
`---
|
||||
model: test/model
|
||||
mode: subagent
|
||||
---
|
||||
Helper agent prompt`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(agentsDir, "nested", "child.md"),
|
||||
`---
|
||||
model: test/model
|
||||
mode: subagent
|
||||
---
|
||||
Nested agent prompt`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
|
||||
expect(config.agent?.["helper"]).toMatchObject({
|
||||
name: "helper",
|
||||
model: "test/model",
|
||||
mode: "subagent",
|
||||
prompt: "Helper agent prompt",
|
||||
})
|
||||
|
||||
expect(config.agent?.["nested/child"]).toMatchObject({
|
||||
name: "nested/child",
|
||||
model: "test/model",
|
||||
mode: "subagent",
|
||||
prompt: "Nested agent prompt",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads commands from .opencode/command (singular)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
const commandDir = path.join(opencodeDir, "command")
|
||||
await fs.mkdir(path.join(commandDir, "nested"), { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(commandDir, "hello.md"),
|
||||
`---
|
||||
description: Test command
|
||||
---
|
||||
Hello from singular command`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(commandDir, "nested", "child.md"),
|
||||
`---
|
||||
description: Nested command
|
||||
---
|
||||
Nested command template`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
|
||||
expect(config.command?.["hello"]).toEqual({
|
||||
description: "Test command",
|
||||
template: "Hello from singular command",
|
||||
})
|
||||
|
||||
expect(config.command?.["nested/child"]).toEqual({
|
||||
description: "Nested command",
|
||||
template: "Nested command template",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads commands from .opencode/commands (plural)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
const commandsDir = path.join(opencodeDir, "commands")
|
||||
await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(commandsDir, "hello.md"),
|
||||
`---
|
||||
description: Test command
|
||||
---
|
||||
Hello from plural commands`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(commandsDir, "nested", "child.md"),
|
||||
`---
|
||||
description: Nested command
|
||||
---
|
||||
Nested command template`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
|
||||
expect(config.command?.["hello"]).toEqual({
|
||||
description: "Test command",
|
||||
template: "Hello from plural commands",
|
||||
})
|
||||
|
||||
expect(config.command?.["nested/child"]).toEqual({
|
||||
description: "Nested command",
|
||||
template: "Nested command template",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("updates config and writes to file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("tool.read external_directory permission", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read env file blocking", () => {
|
||||
describe("tool.read env file permissions", () => {
|
||||
const cases: [string, boolean][] = [
|
||||
[".env", true],
|
||||
[".env.local", true],
|
||||
@@ -136,7 +136,7 @@ describe("tool.read env file blocking", () => {
|
||||
]
|
||||
|
||||
describe.each(["build", "plan"])("agent=%s", (agentName) => {
|
||||
test.each(cases)("%s blocked=%s", async (filename, blocked) => {
|
||||
test.each(cases)("%s asks=%s", async (filename, shouldAsk) => {
|
||||
await using tmp = await tmpdir({
|
||||
init: (dir) => Bun.write(path.join(dir, filename), "content"),
|
||||
})
|
||||
@@ -144,11 +144,15 @@ describe("tool.read env file blocking", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = await Agent.get(agentName)
|
||||
let askedForEnv = false
|
||||
const ctxWithPermissions = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission)
|
||||
if (rule.action === "ask" && req.permission === "read") {
|
||||
askedForEnv = true
|
||||
}
|
||||
if (rule.action === "deny") {
|
||||
throw new PermissionNext.DeniedError(agent.permission)
|
||||
}
|
||||
@@ -156,12 +160,8 @@ describe("tool.read env file blocking", () => {
|
||||
},
|
||||
}
|
||||
const read = await ReadTool.init()
|
||||
const promise = read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions)
|
||||
if (blocked) {
|
||||
await expect(promise).rejects.toThrow(PermissionNext.DeniedError)
|
||||
} else {
|
||||
expect((await promise).output).toContain("content")
|
||||
}
|
||||
await read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions)
|
||||
expect(askedForEnv).toBe(shouldAsk)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
|
||||
describe("tool.registry", () => {
|
||||
test("loads tools from .opencode/tool (singular)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
const toolDir = path.join(opencodeDir, "tool")
|
||||
await fs.mkdir(toolDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(toolDir, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ids = await ToolRegistry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads tools from .opencode/tools (plural)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
const toolsDir = path.join(opencodeDir, "tools")
|
||||
await fs.mkdir(toolsDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(toolsDir, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ids = await ToolRegistry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
@@ -51,6 +51,7 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
"dompurify": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMarked } from "../context/marked"
|
||||
import DOMPurify from "dompurify"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { ComponentProps, createResource, splitProps } from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
|
||||
type Entry = {
|
||||
hash: string
|
||||
@@ -10,6 +12,31 @@ type Entry = {
|
||||
const max = 200
|
||||
const cache = new Map<string, Entry>()
|
||||
|
||||
if (typeof window !== "undefined" && DOMPurify.isSupported) {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return
|
||||
if (node.target !== "_blank") return
|
||||
|
||||
const rel = node.getAttribute("rel") ?? ""
|
||||
const set = new Set(rel.split(/\s+/).filter(Boolean))
|
||||
set.add("noopener")
|
||||
set.add("noreferrer")
|
||||
node.setAttribute("rel", Array.from(set).join(" "))
|
||||
})
|
||||
}
|
||||
|
||||
const config = {
|
||||
USE_PROFILES: { html: true, mathMl: true },
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
FORBID_TAGS: ["style"],
|
||||
FORBID_CONTENTS: ["style", "script"],
|
||||
}
|
||||
|
||||
function sanitize(html: string) {
|
||||
if (!DOMPurify.isSupported) return ""
|
||||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
|
||||
function touch(key: string, value: Entry) {
|
||||
cache.delete(key)
|
||||
cache.set(key, value)
|
||||
@@ -34,6 +61,8 @@ export function Markdown(
|
||||
const [html] = createResource(
|
||||
() => local.text,
|
||||
async (markdown) => {
|
||||
if (isServer) return ""
|
||||
|
||||
const hash = checksum(markdown)
|
||||
const key = local.cacheKey ?? hash
|
||||
|
||||
@@ -46,8 +75,9 @@ export function Markdown(
|
||||
}
|
||||
|
||||
const next = await marked.parse(markdown)
|
||||
if (key && hash) touch(key, { hash, html: next })
|
||||
return next
|
||||
const safe = sanitize(next)
|
||||
if (key && hash) touch(key, { hash, html: safe })
|
||||
return safe
|
||||
},
|
||||
{ initialValue: "" },
|
||||
)
|
||||
|
||||
@@ -22,10 +22,18 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||
const empty: Group[] = []
|
||||
|
||||
const [grouped, { refetch }] = createResource(
|
||||
() => ({
|
||||
filter: store.filter,
|
||||
items: typeof props.items === "function" ? undefined : props.items,
|
||||
}),
|
||||
() => {
|
||||
// When items is a function (not async filter function), call it to track changes
|
||||
const itemsValue =
|
||||
typeof props.items === "function"
|
||||
? (props.items as () => T[])() // Call synchronous function to track it
|
||||
: props.items
|
||||
|
||||
return {
|
||||
filter: store.filter,
|
||||
items: itemsValue,
|
||||
}
|
||||
},
|
||||
async ({ filter, items }) => {
|
||||
const needle = filter?.toLowerCase()
|
||||
const all = (items ?? (await (props.items as (filter: string) => T[] | Promise<T[]>)(needle))) || []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.16",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Vendored
+4
@@ -156,6 +156,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_SESSION_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZenData": {
|
||||
"name": string
|
||||
"type": "sst.cloudflare.Bucket"
|
||||
|
||||
Reference in New Issue
Block a user