Add CLI server and move UI to HTTP API

This commit is contained in:
Shantur Rathore
2025-11-17 18:18:45 +00:00
parent 89bd32814f
commit 08d81f8bb5
40 changed files with 3153 additions and 462 deletions

View File

@@ -0,0 +1,144 @@
import {
BinaryCreateRequest,
BinaryRecord,
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFileUpdate } from "./schema"
export class BinaryRegistry {
constructor(private readonly configStore: ConfigStore, private readonly eventBus?: EventBus) {}
list(): BinaryRecord[] {
return this.mapRecords()
}
resolveDefault(): BinaryRecord {
const binaries = this.mapRecords()
if (binaries.length === 0) {
return this.buildFallbackRecord("opencode")
}
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
}
create(request: BinaryCreateRequest): BinaryRecord {
const entry = {
path: request.path,
version: undefined,
lastUsed: Date.now(),
label: request.label,
}
const config = this.configStore.get()
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path)
const update: ConfigFileUpdate = {
opencodeBinaries: [entry, ...deduped],
}
if (request.makeDefault) {
update.preferences = { lastUsedBinary: request.path }
}
this.configStore.update(update)
const record = this.getById(request.path)
this.emitChange()
return record
}
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
const config = this.configStore.get()
const updatedEntries = config.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
)
const update: ConfigFileUpdate = {
opencodeBinaries: updatedEntries,
}
if (updates.makeDefault) {
update.preferences = { lastUsedBinary: id }
}
this.configStore.update(update)
const record = this.getById(id)
this.emitChange()
return record
}
remove(id: string) {
const config = this.configStore.get()
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
if (config.preferences.lastUsedBinary === id) {
update.preferences = { lastUsedBinary: remaining[0]?.path }
}
this.configStore.update(update)
this.emitChange()
}
validatePath(path: string): BinaryValidationResult {
return this.validateRecord({
id: path,
path,
label: this.prettyLabel(path),
isDefault: false,
})
}
private mapRecords(): BinaryRecord[] {
const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path,
path: binary.path,
label: binary.label ?? this.prettyLabel(binary.path),
version: binary.version,
isDefault: false,
}))
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
const annotated = configuredBinaries.map((binary) => ({
...binary,
isDefault: binary.path === defaultPath,
}))
if (!annotated.some((binary) => binary.path === defaultPath)) {
annotated.unshift(this.buildFallbackRecord(defaultPath))
}
return annotated
}
private getById(id: string): BinaryRecord {
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
}
private emitChange() {
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check.
return { valid: true, version: record.version }
}
private buildFallbackRecord(path: string): BinaryRecord {
return {
id: path,
path,
label: this.prettyLabel(path),
isDefault: true,
}
}
private prettyLabel(path: string) {
const parts = path.split(/[\\/]/)
const last = parts[parts.length - 1] || path
return last || path
}
}

View File

@@ -0,0 +1,80 @@
import { z } from "zod"
const ModelPreferenceSchema = z.object({
providerId: z.string(),
modelId: z.string(),
})
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
agentModelSelections: AgentModelSelectionsSchema.default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
})
const PreferencesUpdateSchema = z.object({
showThinkingBlocks: z.boolean().optional(),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).optional(),
modelRecents: z.array(ModelPreferenceSchema).optional(),
agentModelSelections: AgentModelSelectionsSchema.optional(),
diffViewMode: z.enum(["split", "unified"]).optional(),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(),
})
const RecentFolderSchema = z.object({
path: z.string(),
lastAccessed: z.number().nonnegative(),
})
const OpenCodeBinarySchema = z.object({
path: z.string(),
version: z.string().optional(),
lastUsed: z.number().nonnegative(),
label: z.string().optional(),
})
const ConfigFileSchema = z.object({
preferences: PreferencesSchema.default({}),
recentFolders: z.array(RecentFolderSchema).default([]),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
theme: z.enum(["light", "dark", "system"]).optional(),
})
const ConfigFileUpdateSchema = z.object({
preferences: PreferencesUpdateSchema.optional(),
recentFolders: z.array(RecentFolderSchema).optional(),
opencodeBinaries: z.array(OpenCodeBinarySchema).optional(),
theme: z.enum(["light", "dark", "system"]).optional(),
})
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
export {
ModelPreferenceSchema,
AgentModelSelectionSchema,
AgentModelSelectionsSchema,
PreferencesSchema,
RecentFolderSchema,
OpenCodeBinarySchema,
ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG,
}
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
export type AgentModelSelection = z.infer<typeof AgentModelSelectionSchema>
export type AgentModelSelections = z.infer<typeof AgentModelSelectionsSchema>
export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema>
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>

View File

@@ -0,0 +1,111 @@
import fs from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import {
AgentModelSelections,
ConfigFile,
ConfigFileUpdate,
ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG,
} from "./schema"
export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG
private loaded = false
constructor(private readonly configPath: string, private readonly eventBus?: EventBus) {}
load(): ConfigFile {
if (this.loaded) {
return this.cache
}
try {
const resolved = this.resolvePath(this.configPath)
if (fs.existsSync(resolved)) {
const content = fs.readFileSync(resolved, "utf-8")
const parsed = JSON.parse(content)
this.cache = ConfigFileSchema.parse(parsed)
} else {
this.cache = DEFAULT_CONFIG
}
} catch (error) {
console.warn("Failed to load config", error)
this.cache = DEFAULT_CONFIG
}
this.loaded = true
return this.cache
}
get(): ConfigFile {
return this.load()
}
update(partial: ConfigFile | ConfigFileUpdate) {
const safePartial =
"recentFolders" in partial && "opencodeBinaries" in partial
? ConfigFileSchema.parse(partial)
: ConfigFileUpdateSchema.parse(partial ?? {})
const merged = this.mergeConfig(this.load(), safePartial)
this.cache = ConfigFileSchema.parse(merged)
this.persist()
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
}
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
const mergedPreferences = {
...current.preferences,
...partial.preferences,
environmentVariables: {
...current.preferences.environmentVariables,
...(partial.preferences?.environmentVariables ?? {}),
},
agentModelSelections: this.mergeAgentSelections(
current.preferences.agentModelSelections,
partial.preferences?.agentModelSelections,
),
}
return {
...current,
...partial,
preferences: mergedPreferences,
recentFolders: partial.recentFolders ?? current.recentFolders,
opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries,
}
}
private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) {
if (!update) {
return base
}
const result: AgentModelSelections = { ...base }
for (const [instanceId, agentMap] of Object.entries(update)) {
result[instanceId] = {
...(base[instanceId] ?? {}),
...agentMap,
}
}
return result
}
private persist() {
try {
const resolved = this.resolvePath(this.configPath)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
} catch (error) {
console.warn("Failed to persist config", error)
}
}
private resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
}