refactor(config): remove preferences API

This commit is contained in:
Dax Raad
2026-09-13 19:27:56 -04:00
parent 7784b3ee0d
commit c2cf497e19
20 changed files with 11 additions and 888 deletions
+3 -3
View File
@@ -97,9 +97,9 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 018 | `GET` | `/api/command` | `command.list` | Keep | Request and response accepted as-is. |
| [x] 019 | `GET` | `/api/skill` | `skill.list` | Keep | Renamed `location` to `path`; removed the skill-specific `slash` flag and slash-command behavior. |
| [x] 020 | `GET` | `/api/reference` | `reference.list` | Keep | Removed duplicate `description` and `hidden` fields from nested `source`. |
| [ ] 021 | `GET` | `/api/config` | `config.get` | Change | Removed `claude` and `agents` entry variants; one compatibility plugin now normalizes their skills. |
| [ ] 022 | `GET` | `/api/config/preferences` | `config.preferences` | | |
| [ ] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | | |
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
## Group 3: Credentials, integrations, MCP, and web search
@@ -20,7 +20,6 @@ test("session settings use the remote server context", async ({ page }) => {
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
preferencesUnavailable: true,
})
await configureServers(page)
@@ -326,7 +325,6 @@ type MockServerOptions = {
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
preferencesUnavailable?: boolean
}
async function mockServers(
@@ -416,11 +414,8 @@ async function mockServers(
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/config/shell")
return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/websearch/provider")
return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/config/shell") return json(route, [])
if (url.pathname === "/api/websearch/provider") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
@@ -24,9 +24,6 @@ test.beforeEach(async ({ page }) => {
sandboxes,
},
provider: { all: [], connected: [], default: {} },
preferences: { shell: "zsh", websearch: { provider: "exa" } },
shells: [{ path: "/bin/zsh", name: "zsh", acceptable: true }],
websearchProviders: [{ id: "exa", name: "Exa" }],
sessions: sandboxes.map((directory, index) => ({
id: `ses_settings_${index + 1}`,
title: `Workspace ${index + 1} session`,
@@ -78,38 +75,12 @@ test("single-server settings expose scoped pages without a server picker", async
await settings.getByRole("tab", { name: "Server", exact: true }).click()
await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
await expect(settings.getByText("Third-party search", { exact: true })).toBeVisible()
await expect(settings.getByText("zsh", { exact: true })).toBeVisible()
await expect(settings.getByText("Exa", { exact: true })).toBeVisible()
await settings.getByText("Exa", { exact: true }).click()
const updated = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/config/preferences",
)
await page.getByRole("option", { name: "Any", exact: true }).click()
expect((await updated).postDataJSON()).toEqual({ websearch: { provider: "random" } })
})
test("server details tolerate unavailable preference endpoints", async ({ page }) => {
await page.route(
(url) =>
url.pathname === "/api/config/preferences" ||
url.pathname === "/api/config/shell" ||
url.pathname === "/api/websearch/provider",
(route) => route.fulfill({ status: 404, json: {} }),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Server", exact: true }).click()
const connection = settings.locator('[data-component="settings-server-connection"]')
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(page.getByText("Server request failed", { exact: true })).toHaveCount(0)
})
test("project settings open as a nested autosaving view", async ({ page }) => {
-7
View File
@@ -77,13 +77,6 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json }))
.add(
HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", {
payload: JsonPayload,
success: Json,
}),
)
.add(HttpApiEndpoint.get("configShells", "/api/config/shell", { success: Json }))
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
.add(
-8
View File
@@ -10,7 +10,6 @@ export interface MockServerConfig {
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
preferences?: Record<string, unknown>
shells?: unknown[]
websearchProviders?: unknown[]
directory: string
@@ -198,7 +197,6 @@ const corsHeaders = {
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
const preferences = { current: config.preferences ?? {} }
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
@@ -285,12 +283,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
canonical: project.canonical ?? config.directory,
})
},
configPreferences: () => Effect.succeed(preferences.current),
configUpdatePreferences: (ctx) =>
Effect.sync(() => {
preferences.current = { ...preferences.current, ...ctx.payload }
return preferences.current
}),
configShells: () => Effect.succeed(config.shells ?? []),
websearchProviders: () => Effect.succeed({ location: location(config), data: config.websearchProviders ?? [] }),
worktreeList: () =>
@@ -1,5 +1,4 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ConfigPreferences, ConfigUpdatePreferencesInput } from "@opencode/client/promise"
import { createMemo, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode/ui/theme/context"
import { useTheme } from "@opencode/ui/theme/context"
import {
@@ -16,103 +15,10 @@ import {
} from "@/settings/model"
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
import { createSoundPreviewController } from "./behavior"
import { ServerConnection } from "@/runtime/server/registry"
import { useServerCtx } from "@/runtime/server/runtime"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
export { createShellOptions, createSoundPreviewController } from "./behavior"
export type { ShellOption, ShellSelectOption } from "./behavior"
export function createServerPreferencesController(server: Accessor<ServerConnection.Any>) {
const language = useLanguage()
const serverCtx = useServerCtx(server)
const source = () => ServerConnection.key(server())
const [preferences, preferencesActions] = createResource<ConfigPreferences, ServerConnection.Key>(
source,
() =>
serverCtx()
.sdk.api.config.preferences()
.catch(() => ({})),
{ initialValue: {} },
)
const [shells] = createResource(
source,
() =>
serverCtx()
.sdk.api.config.shells()
.catch(() => []),
{ initialValue: [] },
)
const [providers] = createResource(
source,
() =>
serverCtx()
.sdk.api.websearch.providers()
.then((result) => result.data)
.catch(() => []),
{ initialValue: [] },
)
const update = async (patch: ConfigUpdatePreferencesInput) => {
const context = serverCtx()
const previous = preferences.latest
preferencesActions.mutate({
...previous,
...(patch.shell === undefined ? {} : { shell: patch.shell ?? undefined }),
...(patch.websearch === undefined ? {} : { websearch: patch.websearch ?? undefined }),
})
await context.sdk.api.config
.updatePreferences(patch)
.then(preferencesActions.mutate)
.catch((error: unknown) => {
preferencesActions.mutate(previous)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
})
}
const websearchOptions = createMemo(() => {
const options = providers.latest.map((provider) => ({ value: provider.id, label: provider.name }))
const selected = preferences.latest.websearch
const configured = selected && selected.provider !== "random" ? selected.provider : undefined
return [
{ value: "random" as const, label: language.t("session.websearch.any") },
...options,
...(configured && !options.some((option) => option.value === configured)
? [{ value: configured, label: configured }]
: []),
{ value: false as const, label: language.t("session.websearch.disable") },
]
})
const websearchCurrent = createMemo(() => {
const selection = preferences.latest.websearch
const value = selection === false ? false : (selection?.provider ?? "random")
return websearchOptions().find((option) => option.value === value) ?? websearchOptions()[0]
})
return {
shell: {
shells: () => shells.latest,
current: () => preferences.latest.shell ?? "",
select: (value: string) => {
if (value === (preferences.latest.shell ?? "")) return
void update({ shell: value || null })
},
},
websearch: {
options: websearchOptions,
current: websearchCurrent,
select: (value: string | false) => {
void update({ websearch: value === false ? false : { provider: value } })
},
},
}
}
export function createAppearanceSettingsController() {
const settings = useSettings()
const theme = useTheme()
@@ -208,6 +114,5 @@ export function createSoundSettingsController() {
}
}
export type ShellSettingsController = ReturnType<typeof createServerPreferencesController>["shell"]
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
+1 -37
View File
@@ -18,12 +18,7 @@ import { formatKeybind } from "@/shell/commands/command"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import {
createAppearanceSettingsController,
createShellOptions,
type AppearanceSettingsController,
type ShellSettingsController,
} from "./controllers"
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./controllers"
import "@/settings/settings.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
@@ -96,37 +91,6 @@ const WorkspaceDestinationSetting: Component = () => {
)
}
export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
createShellOptions({
shells: props.controller.shells(),
current: props.controller.current(),
}),
)
return (
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={options()}
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => {
if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault")
if (!option.terminalOnly) return option.name
return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})`
}}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRow>
)
}
const TerminalPlacementSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
@@ -1,5 +1,4 @@
import { Badge } from "@opencode/ui/badge"
import { Select } from "@opencode/ui/select"
import { useDialog } from "@opencode/ui/context/dialog"
import { createMemo, Show, type Component } from "solid-js"
import { ServerRowMenu } from "@/servers/registry/row-menu"
@@ -11,35 +10,9 @@ import { DialogServer } from "@/servers/connect/dialog"
import { AddServerMenu, WslServerSettings } from "@/servers/wsl/settings"
import { SshServerSettings } from "@/servers/ssh/settings"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { ShellSetting } from "@/settings/general/general"
import { createServerPreferencesController } from "@/settings/general/controllers"
import type { SettingsServer } from "./inventory"
import "@/settings/settings.css"
const WebSearchSetting: Component<{
controller: ReturnType<typeof createServerPreferencesController>["websearch"]
}> = (props) => {
const language = useLanguage()
return (
<SettingsRow
title={language.t("settings.server.preferences.websearch.title")}
description={language.t("settings.server.preferences.websearch.description")}
>
<Select
data-action="settings-websearch"
options={props.controller.options()}
current={props.controller.current()}
value={(option) => String(option.value)}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRow>
)
}
export const SettingsServerGeneral: Component<{
entry: SettingsServer
nested?: boolean
@@ -116,25 +89,7 @@ export const SettingsServerGeneral: Component<{
</Show>
</SettingsList>
</section>
<Show when={props.entry.connection} keyed>
{(server) => <ServerPreferences server={server} />}
</Show>
</div>
</>
)
}
function ServerPreferences(props: { server: ServerConnection.Any }) {
const language = useLanguage()
const preferences = createServerPreferencesController(() => props.server)
return (
<section class="settings-section">
<h3 class="settings-section-title">{language.t("settings.tab.preferences")}</h3>
<SettingsList>
<ShellSetting controller={preferences.shell} />
<WebSearchSetting controller={preferences.websearch} />
</SettingsList>
</section>
)
}
-14
View File
@@ -2307,18 +2307,6 @@ export type ConfigGetInput = { readonly location?: { readonly directory?: string
export type ConfigGetOutput = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: ConfigGetInput) => Effect.Effect<ConfigGetOutput, E>
export type ConfigPreferencesOutput = Config.Preferences
export type ConfigPreferencesOperation<E = never> = () => Effect.Effect<ConfigPreferencesOutput, E>
export type ConfigUpdatePreferencesInput = {
readonly shell?: string | null | undefined
readonly websearch?: false | { readonly provider: "random" | WebSearch.ID } | null | undefined
}
export type ConfigUpdatePreferencesOutput = Config.Preferences
export type ConfigUpdatePreferencesOperation<E = never> = (
input?: ConfigUpdatePreferencesInput,
) => Effect.Effect<ConfigUpdatePreferencesOutput, E>
export type ConfigShellsOutput = ReadonlyArray<{
readonly path: string
readonly name: string
@@ -2328,8 +2316,6 @@ export type ConfigShellsOperation<E = never> = () => Effect.Effect<ConfigShellsO
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
readonly preferences: ConfigPreferencesOperation<E>
readonly updatePreferences: ConfigUpdatePreferencesOperation<E>
readonly shells: ConfigShellsOperation<E>
}
@@ -263,9 +263,6 @@ import type {
WebsearchQueryOutput,
ConfigGetInput,
ConfigGetOutput,
ConfigPreferencesOutput,
ConfigUpdatePreferencesInput,
ConfigUpdatePreferencesOutput,
ConfigShellsOutput,
} from "../api/api.js"
import { ClientError } from "./client-error.js"
@@ -1556,23 +1553,11 @@ const EndpointConfigGet = (raw: RawClient["server.config"]) => (input?: ConfigGe
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointConfigPreferences = (raw: RawClient["server.config"]) => () =>
preserveEffect<ConfigPreferencesOutput>()(raw["config.preferences"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointConfigUpdatePreferences = (raw: RawClient["server.config"]) => (input?: ConfigUpdatePreferencesInput) =>
preserveEffect<ConfigUpdatePreferencesOutput>()(
raw["config.updatePreferences"]({ payload: { shell: input?.["shell"], websearch: input?.["websearch"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointConfigShells = (raw: RawClient["server.config"]) => () =>
preserveEffect<ConfigShellsOutput>()(raw["config.shells"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupConfig = (raw: RawClient["server.config"]) => ({
get: EndpointConfigGet(raw),
preferences: EndpointConfigPreferences(raw),
updatePreferences: EndpointConfigUpdatePreferences(raw),
shells: EndpointConfigShells(raw),
})
@@ -259,9 +259,6 @@ import type {
WebsearchQueryOutput,
ConfigGetInput,
ConfigGetOutput,
ConfigPreferencesOutput,
ConfigUpdatePreferencesInput,
ConfigUpdatePreferencesOutput,
ConfigShellsOutput,
} from "./types.js"
import { ClientError } from "./client-error.js"
@@ -2155,29 +2152,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
preferences: (requestOptions?: RequestOptions) =>
request<ConfigPreferencesOutput>(
{
method: "GET",
path: `/api/config/preferences`,
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
updatePreferences: (input?: ConfigUpdatePreferencesInput, requestOptions?: RequestOptions) =>
request<ConfigUpdatePreferencesOutput>(
{
method: "PATCH",
path: `/api/config/preferences`,
body: { shell: input?.["shell"], websearch: input?.["websearch"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
shells: (requestOptions?: RequestOptions) =>
request<ConfigShellsOutput>(
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
@@ -424,8 +424,6 @@ export type WebSearchResult = { url: string; title?: string; content?: string; t
export type ConfigWorktree = { directory: string }
export type ConfigPreferences = { shell?: string; websearch?: false | { provider: "random" | (string & {}) } }
export type ConfigShellOption = { path: string; name: string; acceptable: boolean }
export type SessionMessageLocationSwitched = {
@@ -6211,19 +6209,4 @@ export type ConfigGetInput = {
export type ConfigGetOutput = Array<ConfigEntry>
export type ConfigPreferencesOutput = ConfigPreferences
export type ConfigUpdatePreferencesInput = {
readonly shell?: {
readonly shell?: string | null
readonly websearch?: false | { readonly provider: "random" | (string & {}) } | null
}["shell"]
readonly websearch?: {
readonly shell?: string | null
readonly websearch?: false | { readonly provider: "random" | (string & {}) } | null
}["websearch"]
}
export type ConfigUpdatePreferencesOutput = ConfigPreferences
export type ConfigShellsOutput = Array<ConfigShellOption>
+2 -59
View File
@@ -3,9 +3,9 @@ export * as Config from "./config.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { applyEdits, modify, type ParseError, parse } from "jsonc-parser"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Directory, Document, Info, type Preferences, type PreferencesPatch, type Entry, Event } from "@opencode/schema/config"
import { Directory, Document, Info, type Entry, Event } from "@opencode/schema/config"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
import { Watcher } from "./filesystem/watcher.js"
@@ -38,10 +38,6 @@ export interface Interface {
* source files they parse and rebuild their own state.
*/
readonly changes: () => Stream.Stream<Watcher.Update>
/** Returns preferences from the highest-precedence global config document. */
readonly preferences?: () => Effect.Effect<Preferences, FSUtil.Error>
/** Patches preferences in the highest-precedence global config document. */
readonly updatePreferences?: (patch: PreferencesPatch) => Effect.Effect<Preferences, FSUtil.Error>
}
export const Options = Schema.Struct({
@@ -88,19 +84,6 @@ export const testLayer = (
}),
)
function decodePreferences(text: string): Preferences {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return {}
const normalized = ConfigNormalize.normalize(input)
if (normalized.type === "rejected") return {}
const info = Option.getOrUndefined(Schema.decodeUnknownOption(Info)(normalized.encoded))
return {
...(info?.shell === undefined ? {} : { shell: info.shell }),
...(info?.websearch === undefined ? {} : { websearch: info.websearch }),
}
}
export const layer = (options?: Options) =>
Layer.effect(
Service,
@@ -113,7 +96,6 @@ export const layer = (options?: Options) =>
const globalService = yield* Global.Service
const wellknown = yield* WellKnown.Service
const reloadLock = Semaphore.makeUnsafe(1)
const updateLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
@@ -341,43 +323,6 @@ export const layer = (options?: Options) =>
)
yield* reloadLock.withPermit(reconcile(initial))
const globalConfigPath = Effect.fn("Config.globalConfigPath")(function* () {
const directory = initial.global ?? AbsolutePath.make(globalService.config)
const candidates = ConfigDiscovery.names.map((name) => path.join(directory, name))
const existing = yield* Effect.filter(candidates, fs.isFile)
return existing.at(-1) ?? path.join(directory, "opencode.jsonc")
})
const preferences = Effect.fn("Config.preferences")(function* () {
const filepath = yield* globalConfigPath()
const text = yield* fs.readFileStringSafe(filepath)
return text === undefined ? {} : decodePreferences(text)
})
const updatePreferences = Effect.fn("Config.updatePreferences")(
function* (patch: PreferencesPatch) {
const filepath = yield* globalConfigPath()
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}\n"
const updated = yield* Effect.try({
try: () =>
(["shell", "websearch"] as const).reduce((content, key) => {
if (!Object.prototype.hasOwnProperty.call(patch, key)) return content
return applyEdits(
content,
modify(content, [key], patch[key] === null ? undefined : patch[key], {
formattingOptions: { tabSize: 2, insertSpaces: true },
}),
)
}, text),
catch: (cause) => new FSUtil.FileSystemError({ method: "config.updatePreferences", cause }),
})
yield* fs.writeWithDirs(filepath, updated.endsWith("\n") ? updated : `${updated}\n`)
yield* requestReload
return decodePreferences(updated)
},
(effect) => updateLock.withPermit(effect),
)
return Service.of({
entries: Effect.fnUntraced(function* () {
return configs
@@ -388,8 +333,6 @@ export const layer = (options?: Options) =>
agents: Effect.filter(sources.agents, fs.isDir),
}),
changes: () => Stream.fromPubSub(updates),
preferences,
updatePreferences,
})
}),
)
-146
View File
@@ -12724,98 +12724,6 @@
"summary": "Get configuration"
}
},
"/api/config/preferences": {
"get": {
"tags": ["config"],
"operationId": "config.preferences",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Config.Preferences",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.Preferences"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Return preferences from the highest-precedence global configuration document.",
"summary": "Get global preferences"
},
"patch": {
"tags": ["config"],
"operationId": "config.updatePreferences",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Config.Preferences",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.Preferences"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Patch preferences in the highest-precedence global configuration document.",
"summary": "Update global preferences",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.PreferencesPatch"
}
}
},
"required": true
}
}
},
"/api/config/shell": {
"get": {
"tags": ["config"],
@@ -13686,60 +13594,6 @@
"required": ["package"],
"additionalProperties": false
},
"Config.Preferences": {
"type": "object",
"properties": {
"shell": {
"type": "string"
},
"websearch": {
"anyOf": [
{
"type": "boolean",
"enum": [false]
},
{
"$ref": "#/components/schemas/ConfigWebSearch.InfoEncoded"
}
]
}
},
"additionalProperties": false
},
"Config.PreferencesPatch": {
"type": "object",
"properties": {
"shell": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"websearch": {
"anyOf": [
{
"anyOf": [
{
"type": "boolean",
"enum": [false]
},
{
"$ref": "#/components/schemas/ConfigWebSearch.InfoEncoded"
}
]
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
"Config.ProviderEncoded": {
"type": "object",
"properties": {
-23
View File
@@ -20,29 +20,6 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
}),
),
)
.add(
HttpApiEndpoint.get("config.preferences", "/api/config/preferences", {
success: Config.Preferences,
}).annotateMerge(
OpenApi.annotations({
identifier: "config.preferences",
summary: "Get global preferences",
description: "Return preferences from the highest-precedence global configuration document.",
}),
),
)
.add(
HttpApiEndpoint.patch("config.updatePreferences", "/api/config/preferences", {
payload: Config.PreferencesPatch,
success: Config.Preferences,
}).annotateMerge(
OpenApi.annotations({
identifier: "config.updatePreferences",
summary: "Update global preferences",
description: "Patch preferences in the highest-precedence global configuration document.",
}),
),
)
.add(
HttpApiEndpoint.get("config.shells", "/api/config/shell", {
success: Schema.Array(ConfigShell.Option),
-12
View File
@@ -109,18 +109,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
experimental: ConfigExperimental.Info.pipe(optional),
}) {}
export const Preferences = Schema.Struct({
shell: Schema.String.pipe(optional),
websearch: ConfigWebSearch.Selection.pipe(optional),
}).annotate({ identifier: "Config.Preferences" })
export interface Preferences extends Schema.Schema.Type<typeof Preferences> {}
export const PreferencesPatch = Schema.Struct({
shell: Schema.NullOr(Schema.String).pipe(optional),
websearch: Schema.NullOr(ConfigWebSearch.Selection).pipe(optional),
}).annotate({ identifier: "Config.PreferencesPatch" })
export interface PreferencesPatch extends Schema.Schema.Type<typeof PreferencesPatch> {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: AbsolutePath.pipe(optional),
-16
View File
@@ -7,22 +7,6 @@ import { Api } from "../api"
export const ConfigHandler = HttpApiBuilder.group(Api, "server.config", (handlers) =>
handlers
.handle("config.get", () => Config.Service.use((config) => config.entries()))
.handle(
"config.preferences",
Effect.fn(function* () {
const config = yield* Config.Service
if (!config.preferences) return yield* Effect.die(new Error("Config preferences are unavailable"))
return yield* config.preferences().pipe(Effect.orDie)
}),
)
.handle(
"config.updatePreferences",
Effect.fn(function* (ctx) {
const config = yield* Config.Service
if (!config.updatePreferences) return yield* Effect.die(new Error("Config preference updates are unavailable"))
return yield* config.updatePreferences(ctx.payload).pipe(Effect.orDie)
}),
)
.handle(
"config.shells",
Effect.fn(function* () {
+2 -36
View File
@@ -59,46 +59,12 @@ it.live("returns ordered config entries for the requested directory", () =>
}),
)
it.live("updates global preferences without replacing unrelated JSONC", () =>
it.live("lists available shells", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-preferences-")))
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-shells-")))
const global = path.join(tmp.path, "global")
const config = path.join(global, "opencode.jsonc")
yield* Effect.promise(() => fs.mkdir(global, { recursive: true }))
yield* Effect.promise(() =>
fs.writeFile(
config,
`{
// keep this comment
"model": "provider/model",
"shell": "bash"
}
`,
),
)
const server = yield* startServer(global)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/config/preferences", server.base), {
method: "PATCH",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ shell: null, websearch: { provider: "random" } }),
}),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ websearch: { provider: "random" } })
const text = yield* Effect.promise(() => fs.readFile(config, "utf8"))
expect(text).toContain("// keep this comment")
expect(text).toContain('"model": "provider/model"')
expect(text).not.toContain('"shell"')
expect(text).toContain('"websearch"')
const preferences = yield* Effect.promise(() =>
fetch(new URL("/api/config/preferences", server.base), { headers: server.headers }),
)
expect(preferences.status).toBe(200)
expect(yield* Effect.promise(() => preferences.json())).toEqual({ websearch: { provider: "random" } })
const shells = yield* Effect.promise(() =>
fetch(new URL("/api/config/shell", server.base), { headers: server.headers }),
)
-146
View File
@@ -12724,98 +12724,6 @@
"summary": "Get configuration"
}
},
"/api/config/preferences": {
"get": {
"tags": ["config"],
"operationId": "config.preferences",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Config.Preferences",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.Preferences"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Return preferences from the highest-precedence global configuration document.",
"summary": "Get global preferences"
},
"patch": {
"tags": ["config"],
"operationId": "config.updatePreferences",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Config.Preferences",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.Preferences"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Patch preferences in the highest-precedence global configuration document.",
"summary": "Update global preferences",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.PreferencesPatch"
}
}
},
"required": true
}
}
},
"/api/config/shell": {
"get": {
"tags": ["config"],
@@ -13686,60 +13594,6 @@
"required": ["package"],
"additionalProperties": false
},
"Config.Preferences": {
"type": "object",
"properties": {
"shell": {
"type": "string"
},
"websearch": {
"anyOf": [
{
"type": "boolean",
"enum": [false]
},
{
"$ref": "#/components/schemas/ConfigWebSearch.InfoEncoded"
}
]
}
},
"additionalProperties": false
},
"Config.PreferencesPatch": {
"type": "object",
"properties": {
"shell": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"websearch": {
"anyOf": [
{
"anyOf": [
{
"type": "boolean",
"enum": [false]
},
{
"$ref": "#/components/schemas/ConfigWebSearch.InfoEncoded"
}
]
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
"Config.ProviderEncoded": {
"type": "object",
"properties": {
-146
View File
@@ -12724,98 +12724,6 @@
"summary": "Get configuration"
}
},
"/api/config/preferences": {
"get": {
"tags": ["config"],
"operationId": "config.preferences",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Config.Preferences",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.Preferences"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Return preferences from the highest-precedence global configuration document.",
"summary": "Get global preferences"
},
"patch": {
"tags": ["config"],
"operationId": "config.updatePreferences",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Config.Preferences",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.Preferences"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Patch preferences in the highest-precedence global configuration document.",
"summary": "Update global preferences",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Config.PreferencesPatch"
}
}
},
"required": true
}
}
},
"/api/config/shell": {
"get": {
"tags": ["config"],
@@ -13686,60 +13594,6 @@
"required": ["package"],
"additionalProperties": false
},
"Config.Preferences": {
"type": "object",
"properties": {
"shell": {
"type": "string"
},
"websearch": {
"anyOf": [
{
"type": "boolean",
"enum": [false]
},
{
"$ref": "#/components/schemas/ConfigWebSearch.InfoEncoded"
}
]
}
},
"additionalProperties": false
},
"Config.PreferencesPatch": {
"type": "object",
"properties": {
"shell": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"websearch": {
"anyOf": [
{
"anyOf": [
{
"type": "boolean",
"enum": [false]
},
{
"$ref": "#/components/schemas/ConfigWebSearch.InfoEncoded"
}
]
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
"Config.ProviderEncoded": {
"type": "object",
"properties": {