Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77f463e4e5 | ||
|
|
cb8af962cd | ||
|
|
c333ffa38b | ||
|
|
3456f4ed80 | ||
|
|
2536e9f45b | ||
|
|
9188bc542c | ||
|
|
cbaba10994 | ||
|
|
85d3604309 | ||
|
|
507ba644cf | ||
|
|
3d6f62746a | ||
|
|
2f48c8c05f | ||
|
|
4828fd1eac | ||
|
|
10375263ef | ||
|
|
ae00001aa0 | ||
|
|
f53ebafbab | ||
|
|
23ebc50da9 | ||
|
|
673c6f97b7 | ||
|
|
ec46f71258 | ||
|
|
8865e524cb | ||
|
|
36bb02ae45 | ||
|
|
5072331f04 |
@@ -5,8 +5,11 @@ on:
|
||||
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
stats:
|
||||
if: github.repository == 'sst/opencode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -176,3 +176,4 @@
|
||||
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
||||
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
|
||||
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
|
||||
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"name": "opencode",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
@@ -20,7 +21,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -48,7 +49,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -75,7 +76,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -99,7 +100,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -123,7 +124,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -171,7 +172,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -200,7 +201,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -216,7 +217,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -308,7 +309,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -328,7 +329,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -339,7 +340,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -352,7 +353,7 @@
|
||||
},
|
||||
"packages/tauri": {
|
||||
"name": "@opencode-ai/tauri",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@opencode-ai/desktop": "workspace:*",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
@@ -379,7 +380,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -414,7 +415,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -425,7 +426,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
+4
-1
@@ -574,10 +574,13 @@ async function subscribeSessionEvents() {
|
||||
}
|
||||
|
||||
async function summarize(response: string) {
|
||||
const payload = useContext().payload as IssueCommentEvent
|
||||
try {
|
||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||
} catch (e) {
|
||||
if (isScheduleEvent()) {
|
||||
return "Scheduled task changes"
|
||||
}
|
||||
const payload = useContext().payload as IssueCommentEvent
|
||||
return `Fix issue: ${payload.issue.title}`
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-cpXmqJQJeFj3eED/aOb4YLUdkZFV//7u4f0STBxzUhk="
|
||||
"nodeModules": "sha256-XhU8gEwLPUtzFhMfg+QxExn5/WiDo5VVOiZ0AmklRwc="
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, Show } from "solid-js"
|
||||
@@ -24,6 +25,7 @@ export function Header(props: {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
|
||||
return (
|
||||
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
|
||||
@@ -80,9 +82,18 @@ export function Header(props: {
|
||||
/>
|
||||
</div>
|
||||
<Show when={currentSession()}>
|
||||
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
|
||||
New session
|
||||
</Button>
|
||||
<Tooltip
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>New session</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
|
||||
New session
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -91,7 +102,7 @@ export function Header(props: {
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Toggle terminal</span>
|
||||
<span class="text-icon-base text-12-medium">Ctrl `</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand, formatKeybind } from "@/context/command"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { Identifier } from "@/utils/id"
|
||||
|
||||
@@ -889,8 +889,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
custom
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={cmd.keybind}>
|
||||
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
|
||||
<Show when={command.keybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
@@ -990,26 +990,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
variant="ghost"
|
||||
/>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
dialog.show(() =>
|
||||
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
||||
)
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Cycle agent</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
<Select
|
||||
options={local.agent.list().map((agent) => agent.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Choose model</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
dialog.show(() =>
|
||||
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
|
||||
)
|
||||
}
|
||||
>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@@ -226,6 +226,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
}
|
||||
},
|
||||
keybind(id: string) {
|
||||
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
|
||||
if (!option?.keybind) return ""
|
||||
return formatKeybind(option.keybind)
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
|
||||
@@ -674,7 +674,17 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
|
||||
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
|
||||
<Tooltip
|
||||
class="shrink-0"
|
||||
placement="right"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Toggle sidebar</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
|
||||
</div>
|
||||
}
|
||||
inactive={layout.sidebar.opened()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
@@ -762,7 +772,16 @@ export default function Layout(props: ParentProps) {
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={platform.openDirectoryPickerDialog}>
|
||||
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
|
||||
<Tooltip
|
||||
placement="right"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Open project</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
|
||||
</div>
|
||||
}
|
||||
inactive={layout.sidebar.opened()}
|
||||
>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||
variant="ghost"
|
||||
|
||||
@@ -534,7 +534,7 @@ export default function Page() {
|
||||
const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
|
||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
<div class="min-h-0 grow w-full flex">
|
||||
{/* Session pane - always visible */}
|
||||
<div
|
||||
@@ -662,7 +662,15 @@ export default function Page() {
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||
<Tooltip value="Open file" class="flex items-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Open file</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
|
||||
</div>
|
||||
}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
@@ -774,7 +782,15 @@ export default function Page() {
|
||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Tooltip value="New Terminal" class="flex items-center">
|
||||
<Tooltip
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>New terminal</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
|
||||
</div>
|
||||
}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.183"
|
||||
version = "1.0.185"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/sst/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.183/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.185/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.183/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.185/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.183/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.185/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.183/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.185/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.183/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.185/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -127,6 +127,7 @@ type IssueQueryResponse = {
|
||||
const AGENT_USERNAME = "opencode-agent[bot]"
|
||||
const AGENT_REACTION = "eyes"
|
||||
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
|
||||
const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule"] as const
|
||||
|
||||
// Parses GitHub remote URLs in various formats:
|
||||
// - https://github.com/owner/repo.git
|
||||
@@ -387,22 +388,27 @@ export const GithubRunCommand = cmd({
|
||||
const isMock = args.token || args.event
|
||||
|
||||
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
|
||||
if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") {
|
||||
if (!SUPPORTED_EVENTS.includes(context.eventName as (typeof SUPPORTED_EVENTS)[number])) {
|
||||
core.setFailed(`Unsupported event type: ${context.eventName}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const isScheduleEvent = context.eventName === "schedule"
|
||||
|
||||
const { providerID, modelID } = normalizeModel()
|
||||
const runId = normalizeRunId()
|
||||
const share = normalizeShare()
|
||||
const oidcBaseUrl = normalizeOidcBaseUrl()
|
||||
const { owner, repo } = context.repo
|
||||
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
|
||||
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
|
||||
const actor = context.actor
|
||||
// For schedule events, payload has no issue/comment data
|
||||
const payload = isScheduleEvent
|
||||
? undefined
|
||||
: (context.payload as IssueCommentEvent | PullRequestReviewCommentEvent)
|
||||
const issueEvent = payload && isIssueCommentEvent(payload) ? payload : undefined
|
||||
const actor = isScheduleEvent ? undefined : context.actor
|
||||
|
||||
const issueId =
|
||||
context.eventName === "pull_request_review_comment"
|
||||
const issueId = isScheduleEvent
|
||||
? undefined
|
||||
: context.eventName === "pull_request_review_comment"
|
||||
? (payload as PullRequestReviewCommentEvent).pull_request.number
|
||||
: (payload as IssueCommentEvent).issue.number
|
||||
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
|
||||
@@ -416,9 +422,13 @@ export const GithubRunCommand = cmd({
|
||||
let shareId: string | undefined
|
||||
let exitCode = 0
|
||||
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
|
||||
const triggerCommentId = payload.comment.id
|
||||
const triggerCommentId = payload?.comment.id
|
||||
const useGithubToken = normalizeUseGithubToken()
|
||||
const commentType = context.eventName === "pull_request_review_comment" ? "pr_review" : "issue"
|
||||
const commentType = isScheduleEvent
|
||||
? undefined
|
||||
: context.eventName === "pull_request_review_comment"
|
||||
? "pr_review"
|
||||
: "issue"
|
||||
|
||||
try {
|
||||
if (useGithubToken) {
|
||||
@@ -442,9 +452,11 @@ export const GithubRunCommand = cmd({
|
||||
if (!useGithubToken) {
|
||||
await configureGit(appToken)
|
||||
}
|
||||
await assertPermissions()
|
||||
|
||||
await addReaction(commentType)
|
||||
// Skip permission check for schedule events (no actor to check)
|
||||
if (!isScheduleEvent) {
|
||||
await assertPermissions()
|
||||
await addReaction(commentType!)
|
||||
}
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
@@ -458,11 +470,31 @@ export const GithubRunCommand = cmd({
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
|
||||
// Handle 3 cases
|
||||
// 1. Issue
|
||||
// 2. Local PR
|
||||
// 3. Fork PR
|
||||
if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
|
||||
// Handle 4 cases
|
||||
// 1. Schedule (no issue/PR context)
|
||||
// 2. Issue
|
||||
// 3. Local PR
|
||||
// 4. Fork PR
|
||||
if (isScheduleEvent) {
|
||||
// Schedule event - no issue/PR context, output goes to logs
|
||||
const branch = await checkoutNewBranch("schedule")
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const response = await chat(userPrompt, promptFiles)
|
||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
||||
if (dirty) {
|
||||
const summary = await summarize(response)
|
||||
await pushToNewBranch(summary, branch, uncommittedChanges, true)
|
||||
const pr = await createPR(
|
||||
repoData.data.default_branch,
|
||||
branch,
|
||||
summary,
|
||||
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
|
||||
)
|
||||
console.log(`Created PR #${pr}`)
|
||||
} else {
|
||||
console.log("Response:", response)
|
||||
}
|
||||
} else if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
|
||||
const prData = await fetchPR()
|
||||
// Local PR
|
||||
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
|
||||
@@ -477,7 +509,7 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
|
||||
await createComment(`${response}${footer({ image: !hasShared })}`)
|
||||
await removeReaction(commentType)
|
||||
await removeReaction(commentType!)
|
||||
}
|
||||
// Fork PR
|
||||
else {
|
||||
@@ -492,12 +524,12 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
|
||||
await createComment(`${response}${footer({ image: !hasShared })}`)
|
||||
await removeReaction(commentType)
|
||||
await removeReaction(commentType!)
|
||||
}
|
||||
}
|
||||
// Issue
|
||||
else {
|
||||
const branch = await checkoutNewBranch()
|
||||
const branch = await checkoutNewBranch("issue")
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const issueData = await fetchIssue()
|
||||
const dataPrompt = buildPromptDataForIssue(issueData)
|
||||
@@ -505,7 +537,7 @@ export const GithubRunCommand = cmd({
|
||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
||||
if (dirty) {
|
||||
const summary = await summarize(response)
|
||||
await pushToNewBranch(summary, branch, uncommittedChanges)
|
||||
await pushToNewBranch(summary, branch, uncommittedChanges, false)
|
||||
const pr = await createPR(
|
||||
repoData.data.default_branch,
|
||||
branch,
|
||||
@@ -513,10 +545,10 @@ export const GithubRunCommand = cmd({
|
||||
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
|
||||
)
|
||||
await createComment(`Created PR #${pr}${footer({ image: true })}`)
|
||||
await removeReaction(commentType)
|
||||
await removeReaction(commentType!)
|
||||
} else {
|
||||
await createComment(`${response}${footer({ image: true })}`)
|
||||
await removeReaction(commentType)
|
||||
await removeReaction(commentType!)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -528,8 +560,10 @@ export const GithubRunCommand = cmd({
|
||||
} else if (e instanceof Error) {
|
||||
msg = e.message
|
||||
}
|
||||
await createComment(`${msg}${footer()}`)
|
||||
await removeReaction(commentType)
|
||||
if (!isScheduleEvent) {
|
||||
await createComment(`${msg}${footer()}`)
|
||||
await removeReaction(commentType!)
|
||||
}
|
||||
core.setFailed(msg)
|
||||
// Also output the clean error message for the action to capture
|
||||
//core.setOutput("prepare_error", e.message);
|
||||
@@ -605,6 +639,14 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
async function getUserPrompt() {
|
||||
const customPrompt = process.env["PROMPT"]
|
||||
// For schedule events, PROMPT is required since there's no comment to extract from
|
||||
if (isScheduleEvent) {
|
||||
if (!customPrompt) {
|
||||
throw new Error("PROMPT input is required for scheduled events")
|
||||
}
|
||||
return { userPrompt: customPrompt, promptFiles: [] }
|
||||
}
|
||||
|
||||
if (customPrompt) {
|
||||
return { userPrompt: customPrompt, promptFiles: [] }
|
||||
}
|
||||
@@ -615,7 +657,7 @@ export const GithubRunCommand = cmd({
|
||||
.map((m) => m.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
let prompt = (() => {
|
||||
const body = payload.comment.body.trim()
|
||||
const body = payload!.comment.body.trim()
|
||||
const bodyLower = body.toLowerCase()
|
||||
if (mentions.some((m) => bodyLower === m)) {
|
||||
if (reviewContext) {
|
||||
@@ -865,9 +907,9 @@ export const GithubRunCommand = cmd({
|
||||
await $`git config --local ${config} "${gitConfig}"`
|
||||
}
|
||||
|
||||
async function checkoutNewBranch() {
|
||||
async function checkoutNewBranch(type: "issue" | "schedule") {
|
||||
console.log("Checking out new branch...")
|
||||
const branch = generateBranchName("issue")
|
||||
const branch = generateBranchName(type)
|
||||
await $`git checkout -b ${branch}`
|
||||
return branch
|
||||
}
|
||||
@@ -894,23 +936,32 @@ export const GithubRunCommand = cmd({
|
||||
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
|
||||
}
|
||||
|
||||
function generateBranchName(type: "issue" | "pr") {
|
||||
function generateBranchName(type: "issue" | "pr" | "schedule") {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:-]/g, "")
|
||||
.replace(/\.\d{3}Z/, "")
|
||||
.split("T")
|
||||
.join("")
|
||||
if (type === "schedule") {
|
||||
const hex = crypto.randomUUID().slice(0, 6)
|
||||
return `opencode/scheduled-${hex}-${timestamp}`
|
||||
}
|
||||
return `opencode/${type}${issueId}-${timestamp}`
|
||||
}
|
||||
|
||||
async function pushToNewBranch(summary: string, branch: string, commit: boolean) {
|
||||
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
|
||||
console.log("Pushing to new branch...")
|
||||
if (commit) {
|
||||
await $`git add .`
|
||||
await $`git commit -m "${summary}
|
||||
if (isSchedule) {
|
||||
// No co-author for scheduled events - the schedule is operating as the repo
|
||||
await $`git commit -m "${summary}"`
|
||||
} else {
|
||||
await $`git commit -m "${summary}
|
||||
|
||||
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
}
|
||||
}
|
||||
await $`git push -u origin ${branch}`
|
||||
}
|
||||
@@ -958,6 +1009,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
}
|
||||
|
||||
async function assertPermissions() {
|
||||
// Only called for non-schedule events, so actor is defined
|
||||
console.log(`Asserting permissions for user ${actor}...`)
|
||||
|
||||
let permission
|
||||
@@ -965,7 +1017,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
const response = await octoRest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: actor,
|
||||
username: actor!,
|
||||
})
|
||||
|
||||
permission = response.data.permission
|
||||
@@ -979,30 +1031,32 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
}
|
||||
|
||||
async function addReaction(commentType: "issue" | "pr_review") {
|
||||
// Only called for non-schedule events, so triggerCommentId is defined
|
||||
console.log("Adding reaction...")
|
||||
if (commentType === "pr_review") {
|
||||
return await octoRest.rest.reactions.createForPullRequestReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: triggerCommentId,
|
||||
comment_id: triggerCommentId!,
|
||||
content: AGENT_REACTION,
|
||||
})
|
||||
}
|
||||
return await octoRest.rest.reactions.createForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: triggerCommentId,
|
||||
comment_id: triggerCommentId!,
|
||||
content: AGENT_REACTION,
|
||||
})
|
||||
}
|
||||
|
||||
async function removeReaction(commentType: "issue" | "pr_review") {
|
||||
// Only called for non-schedule events, so triggerCommentId is defined
|
||||
console.log("Removing reaction...")
|
||||
if (commentType === "pr_review") {
|
||||
const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: triggerCommentId,
|
||||
comment_id: triggerCommentId!,
|
||||
content: AGENT_REACTION,
|
||||
})
|
||||
|
||||
@@ -1012,7 +1066,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await octoRest.rest.reactions.deleteForPullRequestComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: triggerCommentId,
|
||||
comment_id: triggerCommentId!,
|
||||
reaction_id: eyesReaction.id,
|
||||
})
|
||||
return
|
||||
@@ -1021,7 +1075,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
const reactions = await octoRest.rest.reactions.listForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: triggerCommentId,
|
||||
comment_id: triggerCommentId!,
|
||||
content: AGENT_REACTION,
|
||||
})
|
||||
|
||||
@@ -1031,17 +1085,18 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await octoRest.rest.reactions.deleteForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: triggerCommentId,
|
||||
comment_id: triggerCommentId!,
|
||||
reaction_id: eyesReaction.id,
|
||||
})
|
||||
}
|
||||
|
||||
async function createComment(body: string) {
|
||||
// Only called for non-schedule events, so issueId is defined
|
||||
console.log("Creating comment...")
|
||||
return await octoRest.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueId,
|
||||
issue_number: issueId!,
|
||||
body,
|
||||
})
|
||||
}
|
||||
@@ -1119,10 +1174,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
||||
}
|
||||
|
||||
function buildPromptDataForIssue(issue: GitHubIssue) {
|
||||
// Only called for non-schedule events, so payload is defined
|
||||
const comments = (issue.comments?.nodes || [])
|
||||
.filter((c) => {
|
||||
const id = parseInt(c.databaseId)
|
||||
return id !== payload.comment.id
|
||||
return id !== payload!.comment.id
|
||||
})
|
||||
.map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
|
||||
|
||||
@@ -1246,10 +1302,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
|
||||
}
|
||||
|
||||
function buildPromptDataForPR(pr: GitHubPullRequest) {
|
||||
// Only called for non-schedule events, so payload is defined
|
||||
const comments = (pr.comments?.nodes || [])
|
||||
.filter((c) => {
|
||||
const id = parseInt(c.databaseId)
|
||||
return id !== payload.comment.id
|
||||
return id !== payload!.comment.id
|
||||
})
|
||||
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ function App() {
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const { event } = useSDK()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme, mode, setMode } = useTheme()
|
||||
const sync = useSync()
|
||||
@@ -417,6 +417,15 @@ function App() {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Open WebUI",
|
||||
value: "webui.open",
|
||||
onSelect: () => {
|
||||
open(sdk.url).catch(() => {})
|
||||
dialog.clear()
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Exit the app",
|
||||
value: "app.exit",
|
||||
@@ -487,11 +496,11 @@ function App() {
|
||||
}
|
||||
})
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.trigger(evt.properties.command)
|
||||
})
|
||||
|
||||
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
toast.show({
|
||||
title: evt.properties.title,
|
||||
message: evt.properties.message,
|
||||
@@ -500,7 +509,7 @@ function App() {
|
||||
})
|
||||
})
|
||||
|
||||
event.on(SessionApi.Event.Deleted.type, (evt) => {
|
||||
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
@@ -510,7 +519,7 @@ function App() {
|
||||
}
|
||||
})
|
||||
|
||||
event.on(SessionApi.Event.Error.type, (evt) => {
|
||||
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
||||
const error = evt.properties.error
|
||||
const message = (() => {
|
||||
if (!error) return "An error occured"
|
||||
@@ -531,7 +540,7 @@ function App() {
|
||||
})
|
||||
})
|
||||
|
||||
event.on(Installation.Event.Updated.type, (evt) => {
|
||||
sdk.event.on(Installation.Event.Updated.type, (evt) => {
|
||||
toast.show({
|
||||
variant: "success",
|
||||
title: "Update Complete",
|
||||
@@ -540,7 +549,7 @@ function App() {
|
||||
})
|
||||
})
|
||||
|
||||
event.on(Installation.Event.UpdateAvailable.type, (evt) => {
|
||||
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
title: "Update Available",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
@@ -70,6 +69,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
return { client: sdk, event: emitter }
|
||||
return { client: sdk, event: emitter, url: props.url }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".erl": "erlang",
|
||||
".ets": "typescript",
|
||||
".hrl": "erlang",
|
||||
".fs": "fsharp",
|
||||
".fsi": "fsharp",
|
||||
|
||||
@@ -697,7 +697,7 @@ export namespace LSPServer {
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow()
|
||||
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
@@ -710,7 +710,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.nothrow()
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
}
|
||||
|
||||
log.info(`installed zls`, { bin })
|
||||
@@ -1003,7 +1003,7 @@ export namespace LSPServer {
|
||||
if (!ok) return
|
||||
}
|
||||
if (tar) {
|
||||
await $`tar -xf ${archive}`.cwd(Global.Path.bin).nothrow()
|
||||
await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
}
|
||||
await fs.rm(archive, { force: true })
|
||||
|
||||
@@ -1014,7 +1014,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.nothrow()
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
}
|
||||
|
||||
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
|
||||
@@ -1580,7 +1580,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.nothrow()
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
}
|
||||
|
||||
log.info(`installed terraform-ls`, { bin })
|
||||
@@ -1663,7 +1663,7 @@ export namespace LSPServer {
|
||||
if (!ok) return
|
||||
}
|
||||
if (ext === "tar.gz") {
|
||||
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).nothrow()
|
||||
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
@@ -1676,7 +1676,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.nothrow()
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
}
|
||||
|
||||
log.info("installed texlab", { bin })
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { type Tool } from "ai"
|
||||
import { experimental_createMCPClient } from "@ai-sdk/mcp"
|
||||
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
|
||||
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 { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod/v4"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Installation } from "../installation"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { McpOAuthProvider } from "./oauth-provider"
|
||||
import { McpOAuthCallback } from "./oauth-callback"
|
||||
@@ -25,7 +27,7 @@ export namespace MCP {
|
||||
}),
|
||||
)
|
||||
|
||||
type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>
|
||||
type MCPClient = Client
|
||||
|
||||
export const Status = z
|
||||
.discriminatedUnion("status", [
|
||||
@@ -71,7 +73,30 @@ export namespace MCP {
|
||||
ref: "MCPStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
|
||||
|
||||
// Convert MCP tool definition to AI SDK Tool type
|
||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
|
||||
const inputSchema = mcpTool.inputSchema
|
||||
|
||||
// Spread first, then override type to ensure it's always "object"
|
||||
const schema: JSONSchema7 = {
|
||||
...(inputSchema as JSONSchema7),
|
||||
type: "object",
|
||||
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
return dynamicTool({
|
||||
description: mcpTool.description ?? "",
|
||||
inputSchema: jsonSchema(schema),
|
||||
execute: async (args: unknown) => {
|
||||
return client.callTool({
|
||||
name: mcpTool.name,
|
||||
arguments: args as Record<string, unknown>,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Store transports for OAuth servers to allow finishing auth
|
||||
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
||||
@@ -81,7 +106,7 @@ export namespace MCP {
|
||||
async () => {
|
||||
const cfg = await Config.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const clients: Record<string, Client> = {}
|
||||
const clients: Record<string, MCPClient> = {}
|
||||
const status: Record<string, Status> = {}
|
||||
|
||||
await Promise.all(
|
||||
@@ -204,10 +229,12 @@ export namespace MCP {
|
||||
let lastError: Error | undefined
|
||||
for (const { name, transport } of transports) {
|
||||
try {
|
||||
mcpClient = await experimental_createMCPClient({
|
||||
const client = new Client({
|
||||
name: "opencode",
|
||||
transport,
|
||||
version: Installation.VERSION,
|
||||
})
|
||||
await client.connect(transport)
|
||||
mcpClient = client
|
||||
log.info("connected", { key, transport: name })
|
||||
status = { status: "connected" }
|
||||
break
|
||||
@@ -248,36 +275,38 @@ export namespace MCP {
|
||||
|
||||
if (mcp.type === "local") {
|
||||
const [cmd, ...args] = mcp.command
|
||||
await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport: new StdioClientTransport({
|
||||
stderr: "ignore",
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
}),
|
||||
const transport = new StdioClientTransport({
|
||||
stderr: "ignore",
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
})
|
||||
.then((client) => {
|
||||
mcpClient = client
|
||||
status = {
|
||||
status: "connected",
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new Client({
|
||||
name: "opencode",
|
||||
version: Installation.VERSION,
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("local mcp startup failed", {
|
||||
key,
|
||||
command: mcp.command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
status = {
|
||||
status: "failed" as const,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
await client.connect(transport)
|
||||
mcpClient = client
|
||||
status = {
|
||||
status: "connected",
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("local mcp startup failed", {
|
||||
key,
|
||||
command: mcp.command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
status = {
|
||||
status: "failed" as const,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
@@ -294,7 +323,7 @@ export namespace MCP {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch((err) => {
|
||||
const result = await withTimeout(mcpClient.listTools(), mcp.timeout ?? 5000).catch((err) => {
|
||||
log.error("failed to get tools from client", { key, error: err })
|
||||
return undefined
|
||||
})
|
||||
@@ -317,7 +346,7 @@ export namespace MCP {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("create() successfully created client", { key, toolCount: Object.keys(result).length })
|
||||
log.info("create() successfully created client", { key, toolCount: result.tools.length })
|
||||
return {
|
||||
mcpClient,
|
||||
status,
|
||||
@@ -392,7 +421,7 @@ export namespace MCP {
|
||||
continue
|
||||
}
|
||||
|
||||
const tools = await client.tools().catch((e) => {
|
||||
const toolsResult = await client.listTools().catch((e) => {
|
||||
log.error("failed to get tools", { clientName, error: e.message })
|
||||
const failedStatus = {
|
||||
status: "failed" as const,
|
||||
@@ -400,14 +429,15 @@ export namespace MCP {
|
||||
}
|
||||
s.status[clientName] = failedStatus
|
||||
delete s.clients[clientName]
|
||||
return undefined
|
||||
})
|
||||
if (!tools) {
|
||||
if (!toolsResult) {
|
||||
continue
|
||||
}
|
||||
for (const [toolName, tool] of Object.entries(tools)) {
|
||||
for (const mcpTool of toolsResult.tools) {
|
||||
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
result[sanitizedClientName + "_" + sanitizedToolName] = tool
|
||||
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client)
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -469,10 +499,11 @@ export namespace MCP {
|
||||
|
||||
// Try to connect - this will trigger the OAuth flow
|
||||
try {
|
||||
await experimental_createMCPClient({
|
||||
const client = new Client({
|
||||
name: "opencode",
|
||||
transport,
|
||||
version: Installation.VERSION,
|
||||
})
|
||||
await client.connect(transport)
|
||||
// If we get here, we're already authenticated
|
||||
return { authorizationUrl: "" }
|
||||
} catch (error) {
|
||||
|
||||
@@ -217,10 +217,10 @@ export namespace ProviderTransform {
|
||||
if (id.includes("gemini-3-pro")) return 1.0
|
||||
if (id.includes("glm-4.6")) return 1.0
|
||||
if (id.includes("minimax-m2")) return 1.0
|
||||
// if (id.includes("kimi-k2")) {
|
||||
// if (id.includes("thinking")) return 1.0
|
||||
// return 0.6
|
||||
// }
|
||||
if (id.includes("kimi-k2")) {
|
||||
if (id.includes("thinking")) return 1.0
|
||||
return 0.6
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -2601,13 +2601,19 @@ export namespace Server {
|
||||
}
|
||||
|
||||
export function listen(opts: { port: number; hostname: string }) {
|
||||
const server = Bun.serve({
|
||||
port: opts.port,
|
||||
const args = {
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
fetch: App().fetch,
|
||||
websocket: websocket,
|
||||
})
|
||||
return server
|
||||
} as const
|
||||
if (opts.port === 0) {
|
||||
try {
|
||||
return Bun.serve({ ...args, port: 4096 })
|
||||
} catch {
|
||||
// port 4096 not available, fall through to use port 0
|
||||
}
|
||||
}
|
||||
return Bun.serve({ ...args, port: opts.port })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,7 +532,11 @@ export namespace SessionPrompt {
|
||||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
|
||||
system: [
|
||||
...(await SystemPrompt.environment()),
|
||||
...(await SystemPrompt.skills()),
|
||||
...(await SystemPrompt.custom()),
|
||||
],
|
||||
messages: [
|
||||
...MessageV2.toModelMessage(sessionMessages),
|
||||
...(isLastStep
|
||||
@@ -1309,7 +1313,7 @@ export namespace SessionPrompt {
|
||||
const results = await Promise.all(
|
||||
shell.map(async ([, cmd]) => {
|
||||
try {
|
||||
return await $`${{ raw: cmd }}`.nothrow().text()
|
||||
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
|
||||
} catch (error) {
|
||||
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ripgrep } from "../file/ripgrep"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Skill } from "../skill"
|
||||
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
@@ -117,4 +118,25 @@ export namespace SystemPrompt {
|
||||
)
|
||||
return Promise.all(found).then((result) => result.filter(Boolean))
|
||||
}
|
||||
|
||||
export async function skills() {
|
||||
const all = await Skill.all()
|
||||
if (all.length === 0) return []
|
||||
|
||||
const lines = [
|
||||
"You have access to skills listed in `<available_skills>`. When a task matches a skill's description, read its SKILL.md file to get detailed instructions.",
|
||||
"",
|
||||
"<available_skills>",
|
||||
]
|
||||
for (const skill of all) {
|
||||
lines.push(" <skill>")
|
||||
lines.push(` <name>${skill.name}</name>`)
|
||||
lines.push(` <description>${skill.description}</description>`)
|
||||
lines.push(` <location>${skill.location}</location>`)
|
||||
lines.push(" </skill>")
|
||||
}
|
||||
lines.push("</available_skills>")
|
||||
|
||||
return [lines.join("\n")]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./skill"
|
||||
@@ -0,0 +1,154 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
|
||||
// Name: 1-64 chars, lowercase alphanumeric and hyphens, no consecutive hyphens, can't start/end with hyphen
|
||||
const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
export const Frontmatter = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine((val) => NAME_REGEX.test(val), {
|
||||
message:
|
||||
"Name must be lowercase alphanumeric with hyphens, no consecutive hyphens, cannot start or end with hyphen",
|
||||
}),
|
||||
description: z.string().min(1).max(1024),
|
||||
license: z.string().optional(),
|
||||
compatibility: z.string().max(500).optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
|
||||
export type Frontmatter = z.infer<typeof Frontmatter>
|
||||
|
||||
export interface Info {
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
license?: string
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export const InvalidError = NamedError.create(
|
||||
"SkillInvalidError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
message: z.string().optional(),
|
||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const NameMismatchError = NamedError.create(
|
||||
"SkillNameMismatchError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
expected: z.string(),
|
||||
actual: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
const SKILL_GLOB = new Bun.Glob("skill/*/SKILL.md")
|
||||
const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
|
||||
|
||||
async function discover(): Promise<string[]> {
|
||||
const directories = await Config.directories()
|
||||
|
||||
const paths: string[] = []
|
||||
|
||||
// Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
|
||||
for (const dir of directories) {
|
||||
for await (const match of SKILL_GLOB.scan({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
paths.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan .claude/skills/ walking up from cwd to worktree
|
||||
for await (const dir of Filesystem.up({
|
||||
targets: [".claude/skills"],
|
||||
start: Instance.directory,
|
||||
stop: Instance.worktree,
|
||||
})) {
|
||||
for await (const match of CLAUDE_SKILL_GLOB.scan({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
paths.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
async function load(skillMdPath: string): Promise<Info> {
|
||||
const md = await ConfigMarkdown.parse(skillMdPath)
|
||||
if (!md.data) {
|
||||
throw new InvalidError({
|
||||
path: skillMdPath,
|
||||
message: "SKILL.md must have YAML frontmatter",
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = Frontmatter.safeParse(md.data)
|
||||
if (!parsed.success) {
|
||||
throw new InvalidError({
|
||||
path: skillMdPath,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
|
||||
const frontmatter = parsed.data
|
||||
const skillDir = path.dirname(skillMdPath)
|
||||
const dirName = path.basename(skillDir)
|
||||
|
||||
if (frontmatter.name !== dirName) {
|
||||
throw new NameMismatchError({
|
||||
path: skillMdPath,
|
||||
expected: dirName,
|
||||
actual: frontmatter.name,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
name: frontmatter.name,
|
||||
description: frontmatter.description,
|
||||
location: skillMdPath,
|
||||
license: frontmatter.license,
|
||||
compatibility: frontmatter.compatibility,
|
||||
metadata: frontmatter.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const paths = await discover()
|
||||
const skills: Info[] = []
|
||||
|
||||
for (const skillPath of paths) {
|
||||
const info = await load(skillPath)
|
||||
log.info("loaded skill", { name: info.name, location: info.location })
|
||||
skills.push(info)
|
||||
}
|
||||
|
||||
return skills
|
||||
})
|
||||
|
||||
export async function all(): Promise<Info[]> {
|
||||
return state()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { SystemPrompt } from "../../src/session/system"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import path from "path"
|
||||
|
||||
test("discovers skills from .opencode/skill/ directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "test-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill for verification.
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
|
||||
Instructions here.
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
expect(skills[0].description).toBe("A test skill for verification.")
|
||||
expect(skills[0].location).toContain("skill/test-skill/SKILL.md")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("discovers multiple skills from .opencode/skill/ directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "my-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: my-skill
|
||||
description: Another test skill.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("my-skill")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error for invalid skill name format", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "InvalidName")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: InvalidName
|
||||
description: A skill with invalid name.
|
||||
---
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(Skill.all()).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when name doesn't match directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "dir-name")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: different-name
|
||||
description: A skill with mismatched name.
|
||||
---
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(Skill.all()).rejects.toThrow("SkillNameMismatchError")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error for missing frontmatter", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "no-frontmatter")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`# No Frontmatter
|
||||
|
||||
Just some content without YAML frontmatter.
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(Skill.all()).rejects.toThrow("SkillInvalidError")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parses optional fields", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "full-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: full-skill
|
||||
description: A skill with all optional fields.
|
||||
license: MIT
|
||||
compatibility: Requires Node.js 18+
|
||||
metadata:
|
||||
author: test-author
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Full Skill
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].license).toBe("MIT")
|
||||
expect(skills[0].compatibility).toBe("Requires Node.js 18+")
|
||||
expect(skills[0].metadata).toEqual({
|
||||
author: "test-author",
|
||||
version: "1.0",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores unknown frontmatter fields", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "extra-fields")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: extra-fields
|
||||
description: A skill with extra unknown fields.
|
||||
allowed-tools: Bash Read Write
|
||||
some-other-field: ignored
|
||||
---
|
||||
|
||||
# Extra Fields Skill
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("extra-fields")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty array when no skills exist", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("SystemPrompt.skills() returns empty array when no skills", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await SystemPrompt.skills()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("SystemPrompt.skills() returns XML block with skills", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "example-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: example-skill
|
||||
description: An example skill for testing XML output.
|
||||
---
|
||||
|
||||
# Example
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await SystemPrompt.skills()
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]).toContain("<available_skills>")
|
||||
expect(result[0]).toContain("<name>example-skill</name>")
|
||||
expect(result[0]).toContain("<description>An example skill for testing XML output.</description>")
|
||||
expect(result[0]).toContain("SKILL.md</location>")
|
||||
expect(result[0]).toContain("</available_skills>")
|
||||
expect(result[0]).toContain("When a task matches a skill's description")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("discovers skills from .claude/skills/ directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".claude", "skills", "claude-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("claude-skill")
|
||||
expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
</head>
|
||||
<body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<script>
|
||||
;(function () {
|
||||
const savedTheme = localStorage.getItem("theme") || "oc-1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/tauri",
|
||||
"private": true,
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow};
|
||||
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
@@ -97,6 +97,11 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
let log_state = app.state::<LogState>();
|
||||
let log_state_clone = log_state.inner().clone();
|
||||
|
||||
let state_dir = app
|
||||
.path()
|
||||
.resolve("", BaseDirectory::AppLocalData)
|
||||
.expect("Failed to resolve app local data dir");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let (mut rx, child) = app
|
||||
.shell()
|
||||
@@ -104,6 +109,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
.unwrap()
|
||||
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
||||
.env("OPENCODE_CLIENT", "desktop")
|
||||
.env("XDG_STATE_HOME", &state_dir)
|
||||
.args(["serve", &format!("--port={port}")])
|
||||
.spawn()
|
||||
.expect("Failed to spawn opencode");
|
||||
@@ -120,6 +126,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
.command(&shell)
|
||||
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
||||
.env("OPENCODE_CLIENT", "desktop")
|
||||
.env("XDG_STATE_HOME", &state_dir)
|
||||
.args([
|
||||
"-il",
|
||||
"-c",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/components/*.tsx",
|
||||
|
||||
@@ -603,7 +603,9 @@ ToolRegistry.register({
|
||||
icon="checklist"
|
||||
trigger={{
|
||||
title: "To-dos",
|
||||
subtitle: `${props.input.todos?.filter((t: any) => t.status === "completed").length}/${props.input.todos?.length}`,
|
||||
subtitle: props.input.todos
|
||||
? `${props.input.todos.filter((t: any) => t.status === "completed").length}/${props.input.todos.length}`
|
||||
: "",
|
||||
}}
|
||||
>
|
||||
<Show when={props.input.todos?.length}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -100,6 +100,56 @@ Or you can set it up manually.
|
||||
|
||||
---
|
||||
|
||||
## Supported Events
|
||||
|
||||
OpenCode can be triggered by the following GitHub events:
|
||||
|
||||
| Event Type | Triggered By | Details |
|
||||
| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads the issue/PR context and can create branches, open PRs, or reply with explanations. |
|
||||
| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context for precise responses. |
|
||||
| `schedule` | Cron-based schedule | Run OpenCode on a schedule using the `prompt` input. Useful for automated code reviews, reports, or maintenance tasks. OpenCode can create issues or PRs as needed. |
|
||||
|
||||
### Schedule Example
|
||||
|
||||
Run OpenCode on a schedule to perform automated tasks:
|
||||
|
||||
```yaml title=".github/workflows/opencode-scheduled.yml"
|
||||
name: Scheduled OpenCode Task
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1" # Every Monday at 9am UTC
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run OpenCode
|
||||
uses: sst/opencode/github@latest
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
with:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
prompt: |
|
||||
Review the codebase for any TODO comments and create a summary.
|
||||
If you find issues worth addressing, open an issue to track them.
|
||||
```
|
||||
|
||||
For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from.
|
||||
|
||||
> **Note:** Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs during a scheduled run.
|
||||
|
||||
---
|
||||
|
||||
## Custom prompts
|
||||
|
||||
Override the default prompt to customize OpenCode's behavior for your workflow.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.0.183",
|
||||
"version": "1.0.185",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user