Merge branch 'dev' into feat/fff-search-tools

This commit is contained in:
Shoubhit Dash 2026-03-20 22:27:25 +05:30 committed by GitHub
commit 96b58aadd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 803 additions and 429 deletions

View file

@ -169,6 +169,70 @@ async function overflow(page: Parameters<typeof test>[0]["page"], file: string)
} }
} }
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => { test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000) test.setTimeout(180_000)
@ -218,6 +282,56 @@ test("review applies inline comment clicks without horizontal overflow", async (
}) })
}) })
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)

View file

@ -1383,11 +1383,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")} accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden" class="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.currentTarget.files?.[0] const list = e.currentTarget.files
if (file) void addAttachment(file) if (list) {
for (const file of Array.from(list)) {
void addAttachment(file)
}
}
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />

View file

@ -1,4 +1,6 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
export { ACCEPTED_FILE_TYPES }
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([ const IMAGE_EXTS = new Map([
@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([
"application/yaml", "application/yaml",
]) ])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096 const SAMPLE = 4096
function kind(type: string) { function kind(type: string) {

View file

@ -0,0 +1,89 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const MIME_EXT = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["application/pdf", "pdf"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
new Set(
ACCEPTED_FILE_TYPES.flatMap((item) => {
if (item.startsWith(".")) return [item.slice(1)]
if (item === "text/*") return TEXT_EXT
const out = MIME_EXT.get(item)
return out ? [out] : []
}),
),
).sort()
export function filePickerFilters(ext?: string[]) {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}

View file

@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string } type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string } type UpdateInfo = { updateAvailable: boolean; version?: string }

View file

@ -1,4 +1,5 @@
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server" export { ServerConnection } from "./context/server"

View file

@ -217,17 +217,6 @@ export function FileTabContent(props: { tab: string }) {
onDelete={controls.remove} onDelete={controls.remove}
/> />
), ),
onDraftPopoverFocusOut: (e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
}
}, 0)
},
}) })
createEffect(() => { createEffect(() => {
@ -426,7 +415,6 @@ export function FileTabContent(props: { tab: string }) {
commentsUi.onLineSelectionEnd(range) commentsUi.onLineSelectionEnd(range)
}} }}
search={search} search={search}
overflow="scroll"
class="select-text" class="select-text"
media={{ media={{
mode: "auto", mode: "auto",

View file

@ -6,6 +6,11 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme,
import { getStore } from "./store" import { getStore } from "./store"
import { setTitlebar } from "./windows" import { setTitlebar } from "./windows"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
type Deps = { type Deps = {
killSidecar: () => void killSidecar: () => void
installCli: () => Promise<string> installCli: () => Promise<string>
@ -94,11 +99,15 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle( ipcMain.handle(
"open-file-picker", "open-file-picker",
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { async (
_event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
) => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
title: opts?.title ?? "Choose a file", title: opts?.title ?? "Choose a file",
defaultPath: opts?.defaultPath, defaultPath: opts?.defaultPath,
filters: pickerFilters(opts?.extensions),
}) })
if (result.canceled) return null if (result.canceled) return null
return opts?.multiple ? result.filePaths : result.filePaths[0] return opts?.multiple ? result.filePaths : result.filePaths[0]

View file

@ -50,6 +50,8 @@ export type ElectronAPI = {
multiple?: boolean multiple?: boolean
title?: string title?: string
defaultPath?: string defaultPath?: string
accept?: string[]
extensions?: string[]
}) => Promise<string | string[] | null> }) => Promise<string | string[] | null>
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null> saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void openLink: (url: string) => void

View file

@ -1,6 +1,8 @@
// @refresh reload // @refresh reload
import { import {
ACCEPTED_FILE_EXTENSIONS,
ACCEPTED_FILE_TYPES,
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
@ -111,6 +113,8 @@ const createPlatform = (): Platform => {
const result = await window.api.openFilePicker({ const result = await window.api.openFilePicker({
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"), title: opts?.title ?? t("desktop.dialog.chooseFile"),
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
}) })
return handleWslPicker(result) return handleWslPicker(result)
}, },

View file

@ -1,6 +1,8 @@
// @refresh reload // @refresh reload
import { import {
ACCEPTED_FILE_EXTENSIONS,
filePickerFilters,
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
@ -98,6 +100,7 @@ const createPlatform = (): Platform => {
directory: false, directory: false,
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"), title: opts?.title ?? t("desktop.dialog.chooseFile"),
filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS),
}) })
return handleWslPicker(result) return handleWslPicker(result)
}, },

View file

@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo"
import { import {
type AccountError, type AccountError,
AccessToken, AccessToken,
Account,
AccountID, AccountID,
DeviceCode, DeviceCode,
Info,
RefreshToken, RefreshToken,
AccountServiceError, AccountServiceError,
Login, Login,
@ -24,10 +24,30 @@ import {
UserCode, UserCode,
} from "./schema" } from "./schema"
export * from "./schema" export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export type AccountOrgs = { export type AccountOrgs = {
account: Account account: Info
orgs: readonly Org[] orgs: readonly Org[]
} }
@ -108,10 +128,10 @@ const mapAccountServiceError =
), ),
) )
export namespace AccountEffect { export namespace Account {
export interface Interface { export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError> readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError> readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError> readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError> readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>

View file

@ -1,31 +1,24 @@
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
Account as AccountSchema,
type AccountError,
type AccessToken,
AccountID,
AccountEffect,
OrgID,
} from "./effect"
export { AccessToken, AccountID, OrgID } from "./effect" export { AccessToken, AccountID, OrgID } from "./effect"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) { function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountEffect.Service.use(f)) return runtime.runSync(S.Service.use(f))
} }
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) { function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountEffect.Service.use(f)) return runtime.runPromise(S.Service.use(f))
} }
export namespace Account { export namespace Account {
export const Account = AccountSchema export const Info = Model
export type Account = AccountSchema export type Info = Model
export function active(): Account | undefined { export function active(): Info | undefined {
return Option.getOrUndefined(runSync((service) => service.active())) return Option.getOrUndefined(runSync((service) => service.active()))
} }

View file

@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql" import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema" import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"] export type AccountRow = (typeof AccountTable)["$inferSelect"]
@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
export namespace AccountRepo { export namespace AccountRepo {
export interface Service { export interface Service {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError> readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
readonly list: () => Effect.Effect<Account[], AccountRepoError> readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError> readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError> readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect( static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo, AccountRepo,
Effect.gen(function* () { Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Account) const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: (db: DbClient) => A) => const query = <A>(f: (db: DbClient) => A) =>
Effect.try({ Effect.try({

View file

@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
) )
export type UserCode = Schema.Schema.Type<typeof UserCode> export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Account extends Schema.Class<Account>("Account")({ export class Info extends Schema.Class<Info>("Account")({
id: AccountID, id: AccountID,
email: Schema.String, email: Schema.String,
url: Schema.String, url: Schema.String,

View file

@ -322,11 +322,11 @@ export namespace Agent {
}), }),
} satisfies Parameters<typeof generateObject>[0] } satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({ const result = streamObject({
...params, ...params,
providerOptions: ProviderTransform.providerOptions(model, { providerOptions: ProviderTransform.providerOptions(model, {
instructions: SystemPrompt.instructions(),
store: false, store: false,
}), }),
onError: () => {}, onError: () => {},

View file

@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export namespace AuthEffect { export namespace Auth {
export interface Interface { export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError> readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError> readonly all: () => Effect.Effect<Record<string, Info>, AuthError>

View file

@ -5,8 +5,8 @@ import * as S from "./effect"
export { OAUTH_DUMMY_KEY } from "./effect" export { OAUTH_DUMMY_KEY } from "./effect"
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) { function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
return runtime.runPromise(S.AuthEffect.Service.use(f)) return runtime.runPromise(S.Auth.Service.use(f))
} }
export namespace Auth { export namespace Auth {

View file

@ -2,7 +2,7 @@ import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect" import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect" import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { type AccountError } from "@/account/schema" import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"
@ -17,7 +17,7 @@ const isActiveOrgChoice = (
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) { const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountEffect.Service const service = yield* Account.Service
yield* Prompt.intro("Log in") yield* Prompt.intro("Log in")
const login = yield* service.login(url) const login = yield* service.login(url)
@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
}) })
const logoutEffect = Effect.fn("logout")(function* (email?: string) { const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountEffect.Service const service = yield* Account.Service
const accounts = yield* service.list() const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in") if (accounts.length === 0) return yield* println("Not logged in")
@ -98,7 +98,7 @@ interface OrgChoice {
} }
const switchEffect = Effect.fn("switch")(function* () { const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountEffect.Service const service = yield* Account.Service
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in") if (groups.length === 0) return yield* println("Not logged in")
@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
}) })
const orgsEffect = Effect.fn("orgs")(function* () { const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountEffect.Service const service = yield* Account.Service
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found") if (groups.length === 0) return yield* println("No accounts found")

View file

@ -58,10 +58,10 @@ export const UpgradeCommand = {
spinner.stop("Upgrade failed", 1) spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) { if (err instanceof Installation.UpgradeFailedError) {
// necessary because choco only allows install/upgrade in elevated terminals // necessary because choco only allows install/upgrade in elevated terminals
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) { if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
prompts.log.error("Please run the terminal as Administrator and try again") prompts.log.error("Please run the terminal as Administrator and try again")
} else { } else {
prompts.log.error(err.data.stderr) prompts.log.error(err.stderr)
} }
} else if (err instanceof Error) prompts.log.error(err.message) } else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done") prompts.outro("Done")

View file

@ -1,17 +1,19 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { AccountEffect } from "@/account/effect" import { Account } from "@/account/effect"
import { AuthEffect } from "@/auth/effect" import { Auth } from "@/auth/effect"
import { Instances } from "@/effect/instances" import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect" import { Installation } from "@/installation"
import { Truncate } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make( export const runtime = ManagedRuntime.make(
Layer.mergeAll( Layer.mergeAll(
AccountEffect.defaultLayer, // Account.defaultLayer, //
TruncateEffect.defaultLayer, Installation.defaultLayer,
Truncate.defaultLayer,
Instances.layer, Instances.layer,
).pipe(Layer.provideMerge(AuthEffect.layer)), ).pipe(Layer.provideMerge(Auth.layer)),
) )
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) { export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {

View file

@ -1,12 +1,13 @@
import { BusEvent } from "@/bus/bus-event" import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { NamedError } from "@opencode-ai/util/error" import { BusEvent } from "@/bus/bus-event"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Process } from "@/util/process" import { Log } from "../util/log"
import { buffer } from "node:stream/consumers"
declare global { declare global {
const OPENCODE_VERSION: string const OPENCODE_VERSION: string
@ -16,39 +17,7 @@ declare global {
export namespace Installation { export namespace Installation {
const log = Log.create({ service: "installation" }) const log = Log.create({ service: "installation" })
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export const Event = { export const Event = {
Updated: BusEvent.define( Updated: BusEvent.define(
@ -75,12 +44,9 @@ export namespace Installation {
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
export async function info() { export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
return { export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
version: VERSION, export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
latest: await latest(),
}
}
export function isPreview() { export function isPreview() {
return CHANNEL !== "latest" return CHANNEL !== "latest"
@ -90,214 +56,300 @@ export namespace Installation {
return CHANNEL === "local" return CHANNEL === "local"
} }
export async function method() { export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" stderr: Schema.String,
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" }) {}
const exec = process.execPath.toLowerCase()
const checks = [ // Response schemas for external version APIs
{ const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
name: "npm" as const, const NpmPackage = Schema.Struct({ version: Schema.String })
command: () => text(["npm", "list", "-g", "--depth=0"]), const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
}, const BrewInfoV2 = Schema.Struct({
{ formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
name: "yarn" as const, })
command: () => text(["yarn", "global", "list"]), const ChocoPackage = Schema.Struct({
}, d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
{ })
name: "pnpm" as const, const ScoopManifest = NpmPackage
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
},
{
name: "bun" as const,
command: () => text(["bun", "pm", "ls", "-g"]),
},
{
name: "brew" as const,
command: () => text(["brew", "list", "--formula", "opencode"]),
},
{
name: "scoop" as const,
command: () => text(["scoop", "list", "opencode"]),
},
{
name: "choco" as const,
command: () => text(["choco", "list", "--limit-output", "opencode"]),
},
]
checks.sort((a, b) => { export interface Interface {
const aMatches = exec.includes(a.name) readonly info: () => Effect.Effect<Info>
const bMatches = exec.includes(b.name) readonly method: () => Effect.Effect<Method>
if (aMatches && !bMatches) return -1 readonly latest: (method?: Method) => Effect.Effect<string>
if (!aMatches && bMatches) return 1 readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
return 0
})
for (const check of checks) {
const output = await check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown"
} }
export const UpgradeFailedError = NamedError.create( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
"UpgradeFailedError",
z.object({
stderr: z.string(),
}),
)
async function getBrewFormula() { export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) Layer.effect(
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" Service,
const coreFormula = await text(["brew", "list", "--formula", "opencode"]) Effect.gen(function* () {
if (coreFormula.includes("opencode")) return "opencode" const http = yield* HttpClient.HttpClient
return "opencode" const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
} const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
export async function upgrade(method: Method, target: string) { const text = Effect.fnUntraced(
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
switch (method) { const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
case "curl": cwd: opts?.cwd,
result = await upgradeCurl(target) env: opts?.env,
break extendEnv: true,
case "npm": })
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) const handle = yield* spawner.spawn(proc)
break const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
case "pnpm": yield* handle.exitCode
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) return out
break },
case "bun": Effect.scoped,
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) Effect.catch(() => Effect.succeed("")),
break )
case "brew": {
const formula = await getBrewFormula() const run = Effect.fnUntraced(
const env = { function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
HOMEBREW_NO_AUTO_UPDATE: "1", const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
...process.env, cwd: opts?.cwd,
} env: opts?.env,
if (formula.includes("/")) { extendEnv: true,
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true }) })
if (tap.code !== 0) { const handle = yield* spawner.spawn(proc)
result = tap const [stdout, stderr] = yield* Effect.all(
break [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
} { concurrency: 2 },
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true }) )
if (repo.code !== 0) { const code = yield* handle.exitCode
result = repo return { code, stdout, stderr }
break },
} Effect.scoped,
const dir = repo.text.trim() Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
if (dir) { )
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) { const getBrewFormula = Effect.fnUntraced(function* () {
result = pull const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
break if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
})
const upgradeCurl = Effect.fnUntraced(
function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const proc = ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, stdout, stderr }
},
Effect.scoped,
Effect.orDie,
)
const methodImpl = Effect.fn("Installation.method")(function* () {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
} }
} }
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break
}
case "choco": return "unknown" as Method
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true }) })
break
case "scoop": const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true }) const detectedMethod = installMethod || (yield* methodImpl())
break
default: if (detectedMethod === "brew") {
throw new Error(`Unknown method: ${method}`) const formula = yield* getBrewFormula()
} if (formula.includes("/")) {
if (!result || result.code !== 0) { const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const stderr = const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || "" return info.formulae[0].versions.stable
throw new UpgradeFailedError({ }
stderr: stderr, const response = yield* httpOk.execute(
}) HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
} HttpClientRequest.acceptJson,
log.info("upgraded", { ),
method, )
target, const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
stdout: result.stdout.toString(), return data.versions.stable
stderr: result.stderr.toString(), }
})
await Process.text([process.execPath, "--version"], { nothrow: true }) if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
const channel = CHANNEL
const response = yield* httpOk.execute(
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}
if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}
if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
}, Effect.orDie)
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
switch (m) {
case "curl":
result = yield* upgradeCurl(target)
break
case "npm":
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
case "pnpm":
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
break
case "bun":
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
break
case "brew": {
const formula = yield* getBrewFormula()
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
if (formula.includes("/")) {
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
if (tap.code !== 0) {
result = tap
break
}
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
const dir = repo.trim()
if (dir) {
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = yield* run(["brew", "upgrade", formula], { env })
break
}
case "choco":
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
break
case "scoop":
result = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
throw new Error(`Unknown method: ${m}`)
}
if (!result || result.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
}
log.info("upgraded", {
method: m,
target,
stdout: result.stdout,
stderr: result.stderr,
})
yield* text([process.execPath, "--version"])
})
return Service.of({
info: Effect.fn("Installation.info")(function* () {
return {
version: VERSION,
latest: yield* latestImpl(),
}
}),
method: methodImpl,
latest: latestImpl,
upgrade: upgradeImpl,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
// Legacy adapters — dynamic import avoids circular dependency since
// foundational modules (db.ts, provider/models.ts) import Installation
// at load time, and runtime transitively loads those same modules.
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
const { runtime } = await import("@/effect/runtime")
return runtime.runPromise(Service.use(f))
} }
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export function info(): Promise<Info> {
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" return runPromise((svc) => svc.info())
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` }
export async function latest(installMethod?: Method) { export function method(): Promise<Method> {
const detectedMethod = installMethod || (await method()) return runPromise((svc) => svc.method())
}
if (detectedMethod === "brew") { export function latest(installMethod?: Method): Promise<string> {
const formula = await getBrewFormula() return runPromise((svc) => svc.latest(installMethod))
if (formula.includes("/")) { }
const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
return version
}
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.versions.stable)
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { export function upgrade(m: Method, target: string): Promise<void> {
const registry = await iife(async () => { return runPromise((svc) => svc.upgrade(m, target))
const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const channel = CHANNEL
return fetch(`${registry}/opencode-ai/${channel}`)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
if (detectedMethod === "choco") {
return fetch(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
{ headers: { Accept: "application/json;odata=verbose" } },
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.d.results[0].Version)
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.tag_name.replace(/^v/, ""))
} }
} }

View file

@ -106,7 +106,7 @@ export namespace ProviderAuth {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const auth = yield* Auth.AuthEffect.Service const auth = yield* Auth.Auth.Service
const hooks = yield* Effect.promise(async () => { const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin") const mod = await import("../plugin")
const plugins = await mod.Plugin.list() const plugins = await mod.Plugin.list()
@ -213,7 +213,7 @@ export namespace ProviderAuth {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer)) export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
export async function methods() { export async function methods() {
return runPromiseInstance(Service.use((svc) => svc.methods())) return runPromiseInstance(Service.use((svc) => svc.methods()))

View file

@ -63,14 +63,14 @@ export namespace LLM {
Provider.getProvider(input.model.providerID), Provider.getProvider(input.model.providerID),
Auth.get(input.model.providerID), Auth.get(input.model.providerID),
]) ])
const isCodex = provider.id === "openai" && auth?.type === "oauth" // TODO: move this to a proper hook
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
const system = [] const system: string[] = []
system.push( system.push(
[ [
// use agent prompt otherwise provider prompt // use agent prompt otherwise provider prompt
// For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call // any custom prompt passed into this call
...input.system, ...input.system,
// any custom prompt from last user message // any custom prompt from last user message
@ -108,10 +108,22 @@ export namespace LLM {
mergeDeep(input.agent.options), mergeDeep(input.agent.options),
mergeDeep(variant), mergeDeep(variant),
) )
if (isCodex) { if (isOpenaiOauth) {
options.instructions = SystemPrompt.instructions() options.instructions = system.join("\n")
} }
const messages = isOpenaiOauth
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = await Plugin.trigger( const params = await Plugin.trigger(
"chat.params", "chat.params",
{ {
@ -146,7 +158,9 @@ export namespace LLM {
) )
const maxOutputTokens = const maxOutputTokens =
isCodex || provider.id.includes("github-copilot") ? undefined : ProviderTransform.maxOutputTokens(input.model) isOpenaiOauth || provider.id.includes("github-copilot")
? undefined
: ProviderTransform.maxOutputTokens(input.model)
const tools = await resolveTools(input) const tools = await resolveTools(input)
@ -217,15 +231,7 @@ export namespace LLM {
...headers, ...headers,
}, },
maxRetries: input.retries ?? 0, maxRetries: input.retries ?? 0,
messages: [ messages,
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
],
model: wrapLanguageModel({ model: wrapLanguageModel({
model: language, model: language,
middleware: [ middleware: [

View file

@ -7,7 +7,7 @@ import PROMPT_DEFAULT from "./prompt/default.txt"
import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_CODEX from "./prompt/codex_header.txt" import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt" import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
@ -15,14 +15,10 @@ import { PermissionNext } from "@/permission"
import { Skill } from "@/skill" import { Skill } from "@/skill"
export namespace SystemPrompt { export namespace SystemPrompt {
export function instructions() {
return PROMPT_CODEX.trim()
}
export function provider(model: Provider.Model) { export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX] if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST] return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) return [PROMPT_CODEX]
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI] if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC] if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY] if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]

View file

@ -9,7 +9,7 @@ import { Log } from "../util/log"
import { ToolID } from "./schema" import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir" import { TRUNCATION_DIR } from "./truncation-dir"
export namespace TruncateEffect { export namespace Truncate {
const log = Log.create({ service: "truncation" }) const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7) const RETENTION = Duration.days(7)

View file

@ -1,6 +1,6 @@
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
import { TruncateEffect as S } from "./truncate-effect" import { Truncate as S } from "./truncate-effect"
export namespace Truncate { export namespace Truncate {
export const MAX_LINES = S.MAX_LINES export const MAX_LINES = S.MAX_LINES

View file

@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo" import { AccountRepo } from "../../src/account/repo"
import { AccountEffect } from "../../src/account/effect" import { Account } from "../../src/account/effect"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db" import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const live = (client: HttpClient.HttpClient) => const live = (client: HttpClient.HttpClient) =>
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) => const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
@ -52,7 +52,7 @@ const deviceTokenClient = (body: unknown, status = 400) =>
) )
const poll = (body: unknown, status = 400) => const poll = (body: unknown, status = 400) =>
AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
it.effect("orgsByAccount groups orgs per account", () => it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () { Effect.gen(function* () {
@ -97,7 +97,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
}), }),
) )
const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]], [AccountID.make("user-1"), [OrgID.make("org-1")]],
@ -135,7 +135,7 @@ it.effect("token refresh persists the new token", () =>
), ),
) )
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined() expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new") expect(String(Option.getOrThrow(token))).toBe("at_new")
@ -178,9 +178,7 @@ it.effect("config sends the selected org header", () =>
}), }),
) )
const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe( const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
Effect.provide(live(client)),
)
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(seen).toEqual({ expect(seen).toEqual({
@ -209,7 +207,7 @@ it.effect("poll stores the account and first org on success", () =>
), ),
) )
const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client))) const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess") expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") { if (res._tag === "PollSuccess") {

View file

@ -1,47 +1,151 @@
import { afterEach, describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Installation } from "../../src/installation" import { Installation } from "../../src/installation"
const fetch0 = globalThis.fetch const encoder = new TextEncoder()
afterEach(() => { function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
globalThis.fetch = fetch0 const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
}) return Layer.succeed(HttpClient.HttpClient, client)
}
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
const spawner = ChildProcessSpawner.make((command) => {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
const output = handler(std?.command ?? "", std?.args ?? [])
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
getOutputFd: () => Stream.empty,
}),
)
})
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
}
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})
}
function testLayer(
httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
spawnHandler?: (cmd: string, args: readonly string[]) => string,
) {
return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(mockSpawner(spawnHandler)))
}
describe("installation", () => { describe("installation", () => {
test("reads release version from GitHub releases", async () => { describe("latest", () => {
globalThis.fetch = (async () => test("reads release version from GitHub releases", async () => {
new Response(JSON.stringify({ tag_name: "v1.2.3" }), { const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
expect(await Installation.latest("unknown")).toBe("1.2.3") const result = await Effect.runPromise(
}) Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.2.3")
})
test("reads scoop manifest versions", async () => { test("strips v prefix from GitHub release tag", async () => {
globalThis.fetch = (async () => const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
new Response(JSON.stringify({ version: "2.3.4" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch
expect(await Installation.latest("scoop")).toBe("2.3.4") const result = await Effect.runPromise(
}) Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)),
)
expect(result).toBe("4.0.0-beta.1")
})
test("reads chocolatey feed versions", async () => { test("reads npm registry versions", async () => {
globalThis.fetch = (async () => const layer = testLayer(
new Response( () => jsonResponse({ version: "1.5.0" }),
JSON.stringify({ (cmd, args) => {
d: { if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
results: [{ Version: "3.4.5" }], return ""
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
}, },
)) as unknown as typeof fetch )
expect(await Installation.latest("choco")).toBe("3.4.5") const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.5.0")
})
test("reads npm registry versions for bun method", async () => {
const layer = testLayer(
() => jsonResponse({ version: "1.6.0" }),
() => "",
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.6.0")
})
test("reads scoop manifest versions", async () => {
const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.3.4")
})
test("reads chocolatey feed versions", async () => {
const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)),
)
expect(result).toBe("3.4.5")
})
test("reads brew formulae API versions", async () => {
const layer = testLayer(
() => jsonResponse({ versions: { stable: "2.0.0" } }),
(cmd, args) => {
// getBrewFormula: return core formula (no tap)
if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.0.0")
})
test("reads brew tap info JSON via CLI", async () => {
const brewInfoJson = JSON.stringify({
formulae: [{ versions: { stable: "2.1.0" } }],
})
const layer = testLayer(
() => jsonResponse({}), // HTTP not used for tap formula
(cmd, args) => {
if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode"
if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
return ""
},
)
const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
)
expect(result).toBe("2.1.0")
})
}) })
}) })

View file

@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect" import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { TruncateEffect } from "../../src/tool/truncate-effect" import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
import { Identifier } from "../../src/id/id" import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process" import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
@ -139,7 +139,7 @@ describe("Truncate", () => {
describe("cleanup", () => { describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer)) const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
it.effect("deletes files older than 7 days and preserves recent files", () => it.effect("deletes files older than 7 days and preserves recent files", () =>
Effect.gen(function* () { Effect.gen(function* () {
@ -152,7 +152,7 @@ describe("Truncate", () => {
yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content") yield* writeFileStringScoped(recent, "recent content")
yield* TruncateEffect.Service.use((s) => s.cleanup()) yield* TruncateSvc.Service.use((s) => s.cleanup())
expect(yield* fs.exists(old)).toBe(false) expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true) expect(yield* fs.exists(recent)).toBe(true)

View file

@ -544,6 +544,47 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
--- ---
### Cloudflare Workers AI
Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models.
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your Account ID and create an API token.
2. Set your Account ID as an environment variable.
```bash title="~/.bash_profile"
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
```
3. Run the `/connect` command and search for **Cloudflare Workers AI**.
```txt
/connect
```
4. Enter your Cloudflare API token.
```txt
┌ API key
└ enter
```
Or set it as an environment variable.
```bash title="~/.bash_profile"
export CLOUDFLARE_API_KEY=your-api-token
```
5. Run the `/models` command to select a model.
```txt
/models
```
---
### Cortecs ### Cortecs
1. Head over to the [Cortecs console](https://cortecs.ai/), create an account, and generate an API key. 1. Head over to the [Cortecs console](https://cortecs.ai/), create an account, and generate an API key.