Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 178983a56b feat(cli): add console account subcommands 2026-03-12 20:37:34 -04:00
11 changed files with 91 additions and 148 deletions
-2
View File
@@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
@@ -18,7 +17,6 @@ export default defineConfig({
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,
+28 -12
View File
@@ -1,13 +1,9 @@
import { Effect } from "effect"
import path from "path"
import { Global } from "../global"
import z from "zod"
import { runtime } from "@/effect/runtime"
import * as S from "./service"
import { Filesystem } from "../util/filesystem"
export { OAUTH_DUMMY_KEY } from "./service"
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
return runtime.runPromise(S.AuthService.use(f))
}
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export namespace Auth {
export const Oauth = z
@@ -39,19 +35,39 @@ export namespace Auth {
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
export type Info = z.infer<typeof Info>
const filepath = path.join(Global.Path.data, "auth.json")
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))
const auth = await all()
return auth[providerID]
}
export async function all(): Promise<Record<string, Info>> {
return runPromise((service) => service.all())
const data = await Filesystem.readJson<Record<string, unknown>>(filepath).catch(() => ({}))
return Object.entries(data).reduce(
(acc, [key, value]) => {
const parsed = Info.safeParse(value)
if (!parsed.success) return acc
acc[key] = parsed.data
return acc
},
{} as Record<string, Info>,
)
}
export async function set(key: string, info: Info) {
return runPromise((service) => service.set(key, info))
const normalized = key.replace(/\/+$/, "")
const data = await all()
if (normalized !== key) delete data[key]
delete data[normalized + "/"]
await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600)
}
export async function remove(key: string) {
return runPromise((service) => service.remove(key))
const normalized = key.replace(/\/+$/, "")
const data = await all()
delete data[key]
delete data[normalized]
await Filesystem.writeJson(filepath, data, 0o600)
}
}
-101
View File
@@ -1,101 +0,0 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
export namespace AuthService {
export interface Service {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
}
}
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
static readonly layer = Layer.effect(
AuthService,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("AuthService.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return AuthService.of({
get,
all,
set,
remove,
})
}),
)
static readonly defaultLayer = AuthService.layer
}
+5 -5
View File
@@ -195,24 +195,24 @@ export const OrgsCommand = cmd({
export const ConsoleCommand = cmd({
command: "console",
describe: "manage console account",
describe: "manage opencode console account",
builder: (yargs) =>
yargs
.command({
...LoginCommand,
describe: "log in to console",
describe: "log in to opencode console",
})
.command({
...LogoutCommand,
describe: "log out from console",
describe: "log out from opencode console",
})
.command({
...SwitchCommand,
describe: "switch active org",
describe: "switch active console org",
})
.command({
...OrgsCommand,
describe: "list orgs",
describe: "list console orgs",
})
.demandCommand(),
async handler() {},
+3 -3
View File
@@ -318,10 +318,10 @@ export const ProvidersLoginCommand = cmd({
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
anthropic: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openai: 3,
google: 4,
openrouter: 5,
vercel: 6,
}
+14
View File
@@ -677,6 +677,20 @@ function App() {
},
])
createEffect(() => {
const currentModel = local.model.current()
if (!currentModel) return
if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
untrack(() => {
DialogAlert.show(
dialog,
"Warning",
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
).then(() => kv.set("openrouter_warning", true))
})
}
})
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})
+2 -3
View File
@@ -1,5 +1,4 @@
import { Layer, ManagedRuntime } from "effect"
import { ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
+5 -1
View File
@@ -3,7 +3,7 @@ import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log"
import { ConsoleCommand } from "./cli/cmd/account"
import { ConsoleCommand, LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade"
@@ -136,6 +136,10 @@ let cli = yargs(hideBin(process.argv))
.command(GenerateCommand)
.command(DebugCommand)
.command(ConsoleCommand)
.command(LoginCommand)
.command(LogoutCommand)
.command(SwitchCommand)
.command(OrgsCommand)
.command(ProvidersCommand)
.command(AgentCommand)
.command(UpgradeCommand)
@@ -23,6 +23,10 @@
max-width: 100%;
gap: 0;
&[data-interrupted] {
color: var(--text-weak);
}
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
@@ -161,6 +165,10 @@
text-align: right;
}
[data-slot="user-message-copy-wrapper"][data-interrupted] {
gap: 12px;
}
&:hover [data-slot="user-message-copy-wrapper"],
&:focus-within [data-slot="user-message-copy-wrapper"] {
opacity: 1;
+22 -12
View File
@@ -131,6 +131,7 @@ export interface MessageProps {
parts: PartType[]
actions?: UserActions
showAssistantCopyPartID?: string | null
interrupted?: boolean
showReasoningSummaries?: boolean
}
@@ -690,7 +691,12 @@ export function Message(props: MessageProps) {
<Switch>
<Match when={props.message.role === "user" && props.message}>
{(userMessage) => (
<UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} actions={props.actions} />
<UserMessageDisplay
message={userMessage() as UserMessage}
parts={props.parts}
actions={props.actions}
interrupted={props.interrupted}
/>
)}
</Match>
<Match when={props.message.role === "assistant" && props.message}>
@@ -881,7 +887,12 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
)
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; actions?: UserActions }) {
export function UserMessageDisplay(props: {
message: UserMessage
parts: PartType[]
actions?: UserActions
interrupted?: boolean
}) {
const data = useData()
const dialog = useDialog()
const i18n = useI18n()
@@ -936,7 +947,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
})
const metaTail = stamp
const metaTail = createMemo(() => {
const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""]
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
})
const openImagePreview = (url: string, alt?: string) => {
dialog.show(() => <ImagePreview src={url} alt={alt} />)
@@ -967,7 +981,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
}
return (
<div data-component="user-message">
<div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}>
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@@ -1007,7 +1021,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
</div>
<div data-slot="user-message-copy-wrapper">
<div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}>
<Show when={metaHead() || metaTail()}>
<span data-slot="user-message-meta-wrap">
<Show when={metaHead()}>
@@ -1291,13 +1305,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
)
}
export function MessageDivider(props: { label: string }) {
PART_MAPPING["compaction"] = function CompactionPartDisplay() {
const i18n = useI18n()
return (
<div data-component="compaction-part">
<div data-slot="compaction-part-divider">
<span data-slot="compaction-part-line" />
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
{props.label}
{i18n.t("ui.messagePart.compaction")}
</span>
<span data-slot="compaction-part-line" />
</div>
@@ -1305,11 +1320,6 @@ export function MessageDivider(props: { label: string }) {
)
}
PART_MAPPING["compaction"] = function CompactionPartDisplay() {
const i18n = useI18n()
return <MessageDivider label={i18n.t("ui.messagePart.compaction")} />
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const i18n = useI18n()
+4 -9
View File
@@ -7,7 +7,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
import { AssistantParts, Message, Part, PART_MAPPING, type UserActions } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -276,11 +276,6 @@ export function SessionTurn(
)
const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
const divider = createMemo(() => {
if (compaction()) return i18n.t("ui.messagePart.compaction")
if (interrupted()) return i18n.t("ui.message.interrupted")
return ""
})
const error = createMemo(
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
)
@@ -389,11 +384,11 @@ export function SessionTurn(
class={props.classes?.container}
>
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={message()!} parts={parts()} actions={props.actions} />
<Message message={message()!} parts={parts()} actions={props.actions} interrupted={interrupted()} />
</div>
<Show when={divider()}>
<Show when={compaction()}>
<div data-slot="session-turn-compaction">
<MessageDivider label={divider()} />
<Part part={compaction()!} message={message()!} hideDetails />
</div>
</Show>
<Show when={assistantMessages().length > 0}>