mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 03:59:54 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5076497e11 | |||
| f31582b907 | |||
| c1abcce820 | |||
| 24d8e41aab |
@@ -75,7 +75,7 @@ jobs:
|
||||
build-cli:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build legacy CLI
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -377,7 +377,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -393,7 +393,6 @@ jobs:
|
||||
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Package
|
||||
if: needs.version.outputs.release
|
||||
@@ -497,31 +496,29 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: opencode-node-cli-*
|
||||
path: packages/cli/dist/node
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -351,8 +351,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.location.directory }
|
||||
const current = info[key]
|
||||
if (current && current.title === next.title && current.directory === next.directory) return
|
||||
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
|
||||
console.log({ tab, session, current })
|
||||
if (current?.title === next.title && current.directory === next.directory) return
|
||||
setInfo(key, next)
|
||||
},
|
||||
select: navigateTab,
|
||||
|
||||
@@ -508,23 +508,8 @@ function toolOutput(result: ToolResultValue) {
|
||||
case "text":
|
||||
case "error":
|
||||
return { type: "text" as const, value: messageValue(result.value) }
|
||||
case "content":
|
||||
return {
|
||||
type: "content" as const,
|
||||
value: result.value.map((item) => {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text }
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
const image = item.mime.toLowerCase().startsWith("image/")
|
||||
if (data !== undefined)
|
||||
return image
|
||||
? { type: "image-data" as const, data, mediaType: item.mime }
|
||||
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
|
||||
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
|
||||
}),
|
||||
}
|
||||
case "json":
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
|
||||
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
|
||||
|
||||
@@ -285,9 +285,8 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cacheWrite: undefined,
|
||||
},
|
||||
outputTokens: {
|
||||
total: responseBody.usage?.completion_tokens ?? undefined,
|
||||
...outputUsage(responseBody.usage),
|
||||
text: undefined,
|
||||
reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
|
||||
},
|
||||
raw: responseBody.usage ?? undefined,
|
||||
},
|
||||
@@ -357,6 +356,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cachedTokens: number | undefined
|
||||
}
|
||||
totalTokens: number | undefined
|
||||
rawCompletionTokens: number | undefined
|
||||
} = {
|
||||
completionTokens: undefined,
|
||||
completionTokensDetails: {
|
||||
@@ -369,6 +369,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
totalTokens: undefined,
|
||||
rawCompletionTokens: undefined,
|
||||
}
|
||||
let isFirstChunk = true
|
||||
const providerOptionsName = this.providerOptionsName
|
||||
@@ -432,11 +433,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
} = value.usage
|
||||
|
||||
usage.promptTokens = prompt_tokens ?? undefined
|
||||
usage.completionTokens = completion_tokens ?? undefined
|
||||
usage.rawCompletionTokens = completion_tokens ?? undefined
|
||||
const output = outputUsage(value.usage)
|
||||
usage.completionTokens = output.total
|
||||
usage.completionTokensDetails.reasoningTokens = output.reasoning
|
||||
usage.totalTokens = total_tokens ?? undefined
|
||||
if (completion_tokens_details?.reasoning_tokens != null) {
|
||||
usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
|
||||
}
|
||||
if (completion_tokens_details?.accepted_prediction_tokens != null) {
|
||||
usage.completionTokensDetails.acceptedPredictionTokens =
|
||||
completion_tokens_details?.accepted_prediction_tokens
|
||||
@@ -708,7 +709,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
},
|
||||
raw: {
|
||||
prompt_tokens: usage.promptTokens ?? null,
|
||||
completion_tokens: usage.completionTokens ?? null,
|
||||
completion_tokens: usage.rawCompletionTokens ?? null,
|
||||
total_tokens: usage.totalTokens ?? null,
|
||||
},
|
||||
},
|
||||
@@ -727,6 +728,7 @@ const openaiCompatibleTokenUsageSchema = z
|
||||
.object({
|
||||
prompt_tokens: z.number().nullish(),
|
||||
completion_tokens: z.number().nullish(),
|
||||
reasoning_tokens: z.number().nullish(),
|
||||
total_tokens: z.number().nullish(),
|
||||
prompt_tokens_details: z
|
||||
.object({
|
||||
@@ -743,6 +745,17 @@ const openaiCompatibleTokenUsageSchema = z
|
||||
})
|
||||
.nullish()
|
||||
|
||||
function outputUsage(usage: z.infer<typeof openaiCompatibleTokenUsageSchema>) {
|
||||
const nested = usage?.completion_tokens_details?.reasoning_tokens
|
||||
return {
|
||||
total:
|
||||
usage?.completion_tokens == null
|
||||
? undefined
|
||||
: usage.completion_tokens + (nested == null ? (usage.reasoning_tokens ?? 0) : 0),
|
||||
reasoning: nested ?? usage?.reasoning_tokens ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// limited version of the schema, focussed on what is needed for the implementation
|
||||
// this approach limits breakages when the API changes and increases efficiency
|
||||
const OpenAICompatibleChatResponseSchema = z.object({
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks"
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { State } from "../state"
|
||||
@@ -15,29 +15,19 @@ export interface Domains {
|
||||
readonly tool: ToolHooks
|
||||
}
|
||||
|
||||
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
|
||||
|
||||
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
|
||||
interface Failures extends Record<keyof Domains, unknown> {
|
||||
readonly aisdk: NoFailures<AISDKHooks>
|
||||
readonly session: NoFailures<SessionHooks>
|
||||
readonly shell: NoFailures<ShellHooks>
|
||||
readonly tool: ToolFailures
|
||||
}
|
||||
|
||||
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
|
||||
callback: Callback<Domains[Domain][Name]>,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
event: Domains[Domain][Name],
|
||||
) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
|
||||
) => Effect.Effect<Domains[Domain][Name]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
|
||||
@@ -66,9 +56,7 @@ const layer = Layer.effect(
|
||||
|
||||
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
|
||||
for (const callback of callbacks.get(key(domain, name)) ?? []) {
|
||||
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
|
||||
event,
|
||||
])
|
||||
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
|
||||
yield* result
|
||||
}
|
||||
return event
|
||||
|
||||
@@ -275,73 +275,6 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves tool result content in AI SDK prompts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
|
||||
mime: "application/pdf",
|
||||
name: "document.pdf",
|
||||
},
|
||||
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
|
||||
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
|
||||
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
output: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
|
||||
{
|
||||
type: "file-data",
|
||||
data: "JVBERg==",
|
||||
mediaType: "application/pdf",
|
||||
filename: "document.pdf",
|
||||
},
|
||||
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
|
||||
{ type: "image-url", url: "https://example.com/pixel.png" },
|
||||
{ type: "file-url", url: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: { total: 19581 },
|
||||
outputTokens: { total: 53 },
|
||||
outputTokens: { total: 187, reasoning: 134 },
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -259,7 +259,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 5778 },
|
||||
outputTokens: { total: 59 },
|
||||
outputTokens: { total: 154, reasoning: 95 },
|
||||
},
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
@@ -391,7 +391,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: { total: 3767 },
|
||||
outputTokens: { total: 19 },
|
||||
outputTokens: { total: 30, reasoning: 11 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
@@ -396,51 +395,4 @@ describe("Plugin", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tool execution when an execute.before hook fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "tool-hook-reject",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name: "echo",
|
||||
options: { codemode: false },
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) =>
|
||||
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.tool
|
||||
.hook("execute.before", () => new ToolFailure({ message: "write disabled" }))
|
||||
.pipe(Effect.asVoid)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const failure = yield* toolSet
|
||||
.execute({
|
||||
sessionID: Session.ID.make("ses_hook_reject"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_hook_reject"),
|
||||
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" })
|
||||
expect(executed).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
|
||||
expect(desktop).toContain("NoDisplay=true")
|
||||
})
|
||||
|
||||
for (const channel of ["dev", "beta"] as const) {
|
||||
test(`bundles the CLI outside the ${channel} app archive`, async () => {
|
||||
test("bundles the CLI outside the dev app archive", async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = "dev"
|
||||
const module = await import("./electron-builder.config.ts?cli-resource")
|
||||
const config = module.default as Configuration
|
||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||
else process.env.OPENCODE_CHANNEL = previous
|
||||
|
||||
expect(config.files).toContain("!resources/opencode-cli*")
|
||||
expect(config.extraResources).toContainEqual({
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
})
|
||||
})
|
||||
|
||||
for (const channel of ["beta", "prod"] as const) {
|
||||
test(`does not bundle the CLI in ${channel} builds`, async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = channel
|
||||
const module = await import(`./electron-builder.config.ts?cli-resource=${channel}`)
|
||||
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
|
||||
const config = module.default as Configuration
|
||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||
else process.env.OPENCODE_CHANNEL = previous
|
||||
|
||||
expect(config.files).toContain("!resources/opencode-cli*")
|
||||
expect(config.extraResources).toContainEqual({
|
||||
expect(config.extraResources).not.toContainEqual({
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("does not bundle the CLI in prod builds", async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = "prod"
|
||||
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
|
||||
const config = module.default as Configuration
|
||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||
else process.env.OPENCODE_CHANNEL = previous
|
||||
|
||||
expect(config.extraResources).not.toContainEqual({
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
|
||||
},
|
||||
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
|
||||
extraResources: [
|
||||
...(channel !== "prod"
|
||||
...(channel === "dev"
|
||||
? [
|
||||
{
|
||||
from: "resources/",
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { $ } from "bun"
|
||||
import * as path from "node:path"
|
||||
|
||||
import { CLI_TARGET } from "./utils"
|
||||
import { RUST_TARGET } from "./utils"
|
||||
|
||||
if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined")
|
||||
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
|
||||
|
||||
const BUNDLE_DIR = "dist"
|
||||
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
|
||||
|
||||
@@ -8,4 +8,3 @@ await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
||||
|
||||
if (channel === "dev") await downloadCliToResources()
|
||||
if (channel === "beta") await downloadCliToResources("next")
|
||||
|
||||
@@ -13,46 +13,46 @@ export function resolveChannel(): Channel {
|
||||
return "dev"
|
||||
}
|
||||
|
||||
export const CLI_BINARIES: Array<{ target: string; package: string; os: string; cpu: string }> = [
|
||||
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
|
||||
{
|
||||
target: "aarch64-apple-darwin",
|
||||
rustTarget: "aarch64-apple-darwin",
|
||||
package: "@opencode-ai/cli-darwin-arm64",
|
||||
os: "darwin",
|
||||
cpu: "arm64",
|
||||
},
|
||||
{
|
||||
target: "x86_64-apple-darwin",
|
||||
rustTarget: "x86_64-apple-darwin",
|
||||
package: "@opencode-ai/cli-darwin-x64-baseline",
|
||||
os: "darwin",
|
||||
cpu: "x64",
|
||||
},
|
||||
{
|
||||
target: "aarch64-pc-windows-msvc",
|
||||
rustTarget: "aarch64-pc-windows-msvc",
|
||||
package: "@opencode-ai/cli-windows-arm64",
|
||||
os: "win32",
|
||||
cpu: "arm64",
|
||||
},
|
||||
{
|
||||
target: "x86_64-pc-windows-msvc",
|
||||
rustTarget: "x86_64-pc-windows-msvc",
|
||||
package: "@opencode-ai/cli-windows-x64-baseline",
|
||||
os: "win32",
|
||||
cpu: "x64",
|
||||
},
|
||||
{
|
||||
target: "x86_64-unknown-linux-gnu",
|
||||
rustTarget: "x86_64-unknown-linux-gnu",
|
||||
package: "@opencode-ai/cli-linux-x64-baseline",
|
||||
os: "linux",
|
||||
cpu: "x64",
|
||||
},
|
||||
{
|
||||
target: "aarch64-unknown-linux-gnu",
|
||||
rustTarget: "aarch64-unknown-linux-gnu",
|
||||
package: "@opencode-ai/cli-linux-arm64",
|
||||
os: "linux",
|
||||
cpu: "arm64",
|
||||
},
|
||||
]
|
||||
|
||||
export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET
|
||||
export const RUST_TARGET = Bun.env.RUST_TARGET
|
||||
|
||||
function nativeTarget() {
|
||||
const { platform, arch } = process
|
||||
@@ -62,19 +62,19 @@ function nativeTarget() {
|
||||
throw new Error(`Unsupported platform: ${platform}/${arch}`)
|
||||
}
|
||||
|
||||
export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
|
||||
const binaryConfig = CLI_BINARIES.find((item) => item.target === target)
|
||||
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
|
||||
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
|
||||
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
|
||||
|
||||
return binaryConfig
|
||||
}
|
||||
|
||||
export async function downloadCliToResources(version = CLI_VERSION) {
|
||||
export async function downloadCliToResources() {
|
||||
const cli = getCurrentCli()
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||
const dest = windowsify("resources/opencode-cli")
|
||||
try {
|
||||
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||
await copyFile(
|
||||
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
|
||||
dest,
|
||||
@@ -88,7 +88,7 @@ export async function downloadCliToResources(version = CLI_VERSION) {
|
||||
}
|
||||
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
|
||||
|
||||
console.log(`Copied ${cli.package}@${version} to ${dest}`)
|
||||
console.log(`Copied ${cli.package} to ${dest}`)
|
||||
}
|
||||
|
||||
export function windowsify(path: string) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Service } from "@opencode-ai/client/service"
|
||||
import { execFile } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
|
||||
@@ -9,34 +8,52 @@ import { app } from "electron"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
const stateHome = process.env.XDG_STATE_HOME
|
||||
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
|
||||
|
||||
type Logger = {
|
||||
log(message: string, meta?: Record<string, unknown>): void
|
||||
error(message: string, meta?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
export async function startBackgroundCli(logger: Logger) {
|
||||
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
|
||||
const bundled = app.isPackaged
|
||||
? join(process.resourcesPath, executableName())
|
||||
: join(root, "../../resources", executableName())
|
||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseVersion(await run(bundled, ["--version"], logger))
|
||||
const version = await run(bundled, ["--version"], logger)
|
||||
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
|
||||
const service = await Service.ensure({
|
||||
version,
|
||||
command: [binary, "serve", "--service"],
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
|
||||
const candidates = [
|
||||
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
|
||||
].filter((candidate) => candidate === undefined || existsSync(candidate))
|
||||
const discovered = await Promise.all(
|
||||
candidates.map(async (candidate) => ({
|
||||
stateHome: candidate,
|
||||
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
|
||||
})),
|
||||
)
|
||||
const found = discovered.find((candidate) => candidate.url !== undefined)
|
||||
logger.log("v2 CLI background instance checked", {
|
||||
detected: Boolean(found),
|
||||
...endpoint(found?.url),
|
||||
})
|
||||
|
||||
const daemonStateHome = found?.stateHome ?? stateHome
|
||||
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
|
||||
const password = await run(binary, ["service", "get", "password"], logger, {
|
||||
redact: true,
|
||||
stateHome: daemonStateHome,
|
||||
})
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
logger.log("v2 CLI background service ready", {
|
||||
username: service.auth.username,
|
||||
version,
|
||||
...endpoint(service.url),
|
||||
existing: Boolean(found),
|
||||
username: "opencode",
|
||||
...endpoint(url),
|
||||
})
|
||||
return {
|
||||
url: service.url,
|
||||
username: service.auth.username,
|
||||
password: service.auth.password,
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +77,21 @@ async function installCli(source: string, version: string, logger: Logger) {
|
||||
return destination
|
||||
}
|
||||
|
||||
async function run(binary: string, args: string[], logger: Logger) {
|
||||
async function run(
|
||||
binary: string,
|
||||
args: string[],
|
||||
logger: Logger,
|
||||
options: { redact?: boolean; stateHome?: string } = {},
|
||||
) {
|
||||
logger.log("v2 CLI command started", { binary, args })
|
||||
return execFileAsync(binary, args, { windowsHide: true }).then(
|
||||
const env = { ...process.env }
|
||||
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
|
||||
else env.XDG_STATE_HOME = options.stateHome
|
||||
return execFileAsync(binary, args, { env, windowsHide: true }).then(
|
||||
(result) => {
|
||||
const stdout = result.stdout.trim()
|
||||
const stderr = result.stderr.trim()
|
||||
logger.log("v2 CLI command completed", { args, stdout, stderr })
|
||||
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
|
||||
return stdout
|
||||
},
|
||||
(error: unknown) => {
|
||||
@@ -74,7 +99,7 @@ async function run(binary: string, args: string[], logger: Logger) {
|
||||
logger.error("v2 CLI command failed", {
|
||||
args,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stdout: output.stdout?.trim() ?? "",
|
||||
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
|
||||
stderr: output.stderr?.trim() ?? "",
|
||||
})
|
||||
throw error
|
||||
@@ -82,11 +107,11 @@ async function run(binary: string, args: string[], logger: Logger) {
|
||||
)
|
||||
}
|
||||
|
||||
function parseVersion(output: string) {
|
||||
const marker = output.lastIndexOf(" v")
|
||||
const version = marker === -1 ? output : output.slice(marker + 2)
|
||||
if (!version) throw new Error("V2 CLI did not provide a version")
|
||||
return version
|
||||
function serviceUrl(status: string) {
|
||||
if (URL.canParse(status)) return status
|
||||
if (!status.startsWith("running ")) return
|
||||
const url = status.slice("running ".length).trim()
|
||||
return URL.canParse(url) ? url : undefined
|
||||
}
|
||||
|
||||
function endpoint(url: string | undefined) {
|
||||
|
||||
@@ -181,7 +181,7 @@ const main = Effect.gen(function* () {
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv()
|
||||
const shellEnv = preferAppEnv(app.getPath("userData"))
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
@@ -310,7 +310,7 @@ const main = Effect.gen(function* () {
|
||||
useEnvProxy()
|
||||
|
||||
logger.log("starting v2 background service")
|
||||
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url: sidecar.url,
|
||||
username: sidecar.username,
|
||||
|
||||
@@ -17,16 +17,17 @@ export function setDefaultServerUrl(url: string | null) {
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
}
|
||||
|
||||
export function preferAppEnv() {
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
|
||||
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
|
||||
Object.assign(process.env, {
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
})
|
||||
return shellEnv
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
|
||||
@@ -4,11 +4,9 @@ export interface Registration {
|
||||
readonly dispose: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
|
||||
Name extends keyof Spec,
|
||||
>(
|
||||
export type Hooks<Spec> = <Name extends keyof Spec>(
|
||||
name: Name,
|
||||
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
|
||||
callback: (input: Spec[Name]) => Effect.Effect<void>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
@@ -38,13 +38,7 @@ export interface ToolHooks {
|
||||
)
|
||||
}
|
||||
|
||||
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
|
||||
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
|
||||
readonly "execute.before": Tool.Error
|
||||
readonly "execute.after": never
|
||||
}
|
||||
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolDraft>
|
||||
readonly hook: Hooks<ToolHooks, ToolFailures>
|
||||
readonly hook: Hooks<ToolHooks>
|
||||
}
|
||||
|
||||
@@ -650,18 +650,8 @@ export function Session() {
|
||||
slash: {
|
||||
name: "compact",
|
||||
},
|
||||
run: async () => {
|
||||
const selection = local.model.current()
|
||||
if (selection)
|
||||
await client.api.session.switchModel({
|
||||
sessionID: route.sessionID,
|
||||
model: {
|
||||
providerID: selection.providerID,
|
||||
id: selection.modelID,
|
||||
variant: local.model.variant.current(),
|
||||
},
|
||||
})
|
||||
await client.api.session.compact({ sessionID: route.sessionID })
|
||||
run: () => {
|
||||
void client.api.session.compact({ sessionID: route.sessionID })
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
+18
-20
@@ -35,34 +35,32 @@ if (Script.release && !Script.preview) {
|
||||
|
||||
await prepareReleaseFiles()
|
||||
|
||||
if (Script.channel !== "beta") {
|
||||
console.log("\n=== schema ===\n")
|
||||
await $`bun ./packages/schema/script/publish.ts`
|
||||
console.log("\n=== schema ===\n")
|
||||
await $`bun ./packages/schema/script/publish.ts`
|
||||
|
||||
console.log("\n=== theme ===\n")
|
||||
await $`bun ./packages/theme/script/publish.ts`
|
||||
console.log("\n=== theme ===\n")
|
||||
await $`bun ./packages/theme/script/publish.ts`
|
||||
|
||||
console.log("\n=== ai ===\n")
|
||||
await $`bun ./packages/ai/script/publish.ts`
|
||||
console.log("\n=== ai ===\n")
|
||||
await $`bun ./packages/ai/script/publish.ts`
|
||||
|
||||
console.log("\n=== util ===\n")
|
||||
await $`bun ./packages/util/script/publish.ts`
|
||||
console.log("\n=== util ===\n")
|
||||
await $`bun ./packages/util/script/publish.ts`
|
||||
|
||||
console.log("\n=== protocol ===\n")
|
||||
await $`bun ./packages/protocol/script/publish.ts`
|
||||
console.log("\n=== protocol ===\n")
|
||||
await $`bun ./packages/protocol/script/publish.ts`
|
||||
|
||||
console.log("\n=== client ===\n")
|
||||
await $`bun ./packages/client/script/publish.ts`
|
||||
console.log("\n=== client ===\n")
|
||||
await $`bun ./packages/client/script/publish.ts`
|
||||
|
||||
console.log("\n=== cli ===\n")
|
||||
await $`bun ./packages/cli/script/publish.ts`
|
||||
console.log("\n=== cli ===\n")
|
||||
await $`bun ./packages/cli/script/publish.ts`
|
||||
|
||||
console.log("\n=== plugin ===\n")
|
||||
await $`bun ./packages/plugin/script/publish.ts`
|
||||
console.log("\n=== plugin ===\n")
|
||||
await $`bun ./packages/plugin/script/publish.ts`
|
||||
|
||||
console.log("\n=== ui ===\n")
|
||||
await $`bun ./packages/ui/script/publish.ts`
|
||||
}
|
||||
console.log("\n=== ui ===\n")
|
||||
await $`bun ./packages/ui/script/publish.ts`
|
||||
|
||||
if (Script.release) {
|
||||
await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`
|
||||
|
||||
Reference in New Issue
Block a user