mirror of
https://github.com/sst/opencode.git
synced 2025-07-08 00:25:00 +00:00
lazy load formatters
This commit is contained in:
parent
334161a30e
commit
2ec0611f42
19 changed files with 408 additions and 435 deletions
|
@ -41,5 +41,9 @@
|
|||
],
|
||||
"patchedDependencies": {
|
||||
"ai@4.3.16": "patches/ai@4.3.16.patch"
|
||||
}
|
||||
},
|
||||
"randomField": "purple-elephant-42",
|
||||
"mysteriousData": "cosmic-banana-7891",
|
||||
"quirkyValue": "dancing-octopus-314",
|
||||
"whimsicalEntry": "flying-penguin-2024"
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ import "zod-openapi/extend"
|
|||
import { Log } from "../util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Project } from "../util/project"
|
||||
import { Global } from "../global"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
|
@ -13,7 +12,6 @@ export namespace App {
|
|||
|
||||
export const Info = z
|
||||
.object({
|
||||
project: z.string(),
|
||||
user: z.string(),
|
||||
hostname: z.string(),
|
||||
git: z.boolean(),
|
||||
|
@ -33,11 +31,21 @@ export namespace App {
|
|||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
const ctx = Context.create<Awaited<ReturnType<typeof create>>>("app")
|
||||
const ctx = Context.create<{
|
||||
info: Info
|
||||
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
|
||||
}>("app")
|
||||
|
||||
const APP_JSON = "app.json"
|
||||
|
||||
async function create(input: { cwd: string }) {
|
||||
export type Input = {
|
||||
cwd: string
|
||||
}
|
||||
|
||||
export async function provide<T>(
|
||||
input: Input,
|
||||
cb: (app: App.Info) => Promise<T>,
|
||||
) {
|
||||
log.info("creating", {
|
||||
cwd: input.cwd,
|
||||
})
|
||||
|
@ -66,10 +74,8 @@ export namespace App {
|
|||
>()
|
||||
|
||||
const root = git ?? input.cwd
|
||||
const project = await Project.getName(root)
|
||||
|
||||
const info: Info = {
|
||||
project: project,
|
||||
user: os.userInfo().username,
|
||||
hostname: os.hostname(),
|
||||
time: {
|
||||
|
@ -84,12 +90,20 @@ export namespace App {
|
|||
cwd: input.cwd,
|
||||
},
|
||||
}
|
||||
const result = {
|
||||
const app = {
|
||||
services,
|
||||
info,
|
||||
}
|
||||
|
||||
return result
|
||||
return ctx.provide(app, async () => {
|
||||
const result = await cb(app.info)
|
||||
for (const [key, entry] of app.services.entries()) {
|
||||
if (!entry.shutdown) continue
|
||||
log.info("shutdown", { name: key })
|
||||
await entry.shutdown?.(await entry.state)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export function state<State>(
|
||||
|
@ -115,22 +129,6 @@ export namespace App {
|
|||
return ctx.use().info
|
||||
}
|
||||
|
||||
export async function provide<T>(
|
||||
input: { cwd: string },
|
||||
cb: (app: Info) => Promise<T>,
|
||||
) {
|
||||
const app = await create(input)
|
||||
return ctx.provide(app, async () => {
|
||||
const result = await cb(app.info)
|
||||
for (const [key, entry] of app.services.entries()) {
|
||||
if (!entry.shutdown) continue
|
||||
log.info("shutdown", { name: key })
|
||||
await entry.shutdown?.(await entry.state)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export async function initialize() {
|
||||
const { info } = ctx.use()
|
||||
info.time.initialized = Date.now()
|
||||
|
|
|
@ -49,7 +49,7 @@ export namespace Bus {
|
|||
)
|
||||
}
|
||||
|
||||
export function publish<Definition extends EventDefinition>(
|
||||
export async function publish<Definition extends EventDefinition>(
|
||||
def: Definition,
|
||||
properties: z.output<Definition["properties"]>,
|
||||
) {
|
||||
|
@ -60,12 +60,14 @@ export namespace Bus {
|
|||
log.info("publishing", {
|
||||
type: def.type,
|
||||
})
|
||||
const pending = []
|
||||
for (const key of [def.type, "*"]) {
|
||||
const match = state().subscriptions.get(key)
|
||||
for (const sub of match ?? []) {
|
||||
sub(payload)
|
||||
pending.push(sub(payload))
|
||||
}
|
||||
}
|
||||
return Promise.all(pending)
|
||||
}
|
||||
|
||||
export function subscribe<Definition extends EventDefinition>(
|
||||
|
|
17
packages/opencode/src/cli/bootstrap.ts
Normal file
17
packages/opencode/src/cli/bootstrap.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { App } from "../app/app"
|
||||
import { ConfigHooks } from "../config/hooks"
|
||||
import { Format } from "../format"
|
||||
import { Share } from "../share/share"
|
||||
|
||||
export async function bootstrap<T>(
|
||||
input: App.Input,
|
||||
cb: (app: App.Info) => Promise<T>,
|
||||
) {
|
||||
return App.provide(input, async (app) => {
|
||||
Share.init()
|
||||
Format.init()
|
||||
ConfigHooks.init()
|
||||
|
||||
return cb(app)
|
||||
})
|
||||
}
|
|
@ -1,14 +1,13 @@
|
|||
import type { Argv } from "yargs"
|
||||
import { App } from "../../app/app"
|
||||
import { Bus } from "../../bus"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Session } from "../../session"
|
||||
import { Share } from "../../share/share"
|
||||
import { Message } from "../../session/message"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Config } from "../../config/config"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
|
@ -56,118 +55,109 @@ export const RunCommand = cmd({
|
|||
},
|
||||
handler: async (args) => {
|
||||
const message = args.message.join(" ")
|
||||
await App.provide(
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
async () => {
|
||||
await Share.init()
|
||||
const session = await (async () => {
|
||||
if (args.continue) {
|
||||
const first = await Session.list().next()
|
||||
if (first.done) return
|
||||
return first.value
|
||||
}
|
||||
|
||||
if (args.session) return Session.get(args.session)
|
||||
|
||||
return Session.create()
|
||||
})()
|
||||
|
||||
if (!session) {
|
||||
UI.error("Session not found")
|
||||
return
|
||||
await bootstrap({ cwd: process.cwd() }, async () => {
|
||||
const session = await (async () => {
|
||||
if (args.continue) {
|
||||
const first = await Session.list().next()
|
||||
if (first.done) return
|
||||
return first.value
|
||||
}
|
||||
|
||||
const isPiped = !process.stdout.isTTY
|
||||
if (args.session) return Session.get(args.session)
|
||||
|
||||
UI.empty()
|
||||
UI.println(UI.logo())
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
return Session.create()
|
||||
})()
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||
await Session.share(session.id)
|
||||
UI.println(
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://opencode.ai/s/" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
}
|
||||
UI.empty()
|
||||
if (!session) {
|
||||
UI.error("Session not found")
|
||||
return
|
||||
}
|
||||
|
||||
const { providerID, modelID } = args.model
|
||||
? Provider.parseModel(args.model)
|
||||
: await Provider.defaultModel()
|
||||
const isPiped = !process.stdout.isTTY
|
||||
|
||||
UI.empty()
|
||||
UI.println(UI.logo())
|
||||
UI.empty()
|
||||
UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message)
|
||||
UI.empty()
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (cfg.autoshare || Flag.OPENCODE_AUTO_SHARE || args.share) {
|
||||
await Session.share(session.id)
|
||||
UI.println(
|
||||
UI.Style.TEXT_NORMAL_BOLD + "@ ",
|
||||
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
|
||||
UI.Style.TEXT_INFO_BOLD +
|
||||
"~ https://opencode.ai/s/" +
|
||||
session.id.slice(-8),
|
||||
)
|
||||
UI.empty()
|
||||
}
|
||||
UI.empty()
|
||||
|
||||
function printEvent(color: string, type: string, title: string) {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
UI.Style.TEXT_NORMAL +
|
||||
UI.Style.TEXT_DIM +
|
||||
` ${type.padEnd(7, " ")}`,
|
||||
"",
|
||||
UI.Style.TEXT_NORMAL + title,
|
||||
)
|
||||
const { providerID, modelID } = args.model
|
||||
? Provider.parseModel(args.model)
|
||||
: await Provider.defaultModel()
|
||||
UI.println(
|
||||
UI.Style.TEXT_NORMAL_BOLD + "@ ",
|
||||
UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`,
|
||||
)
|
||||
UI.empty()
|
||||
|
||||
function printEvent(color: string, type: string, title: string) {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
|
||||
"",
|
||||
UI.Style.TEXT_NORMAL + title,
|
||||
)
|
||||
}
|
||||
|
||||
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
|
||||
if (evt.properties.sessionID !== session.id) return
|
||||
const part = evt.properties.part
|
||||
const message = await Session.getMessage(
|
||||
evt.properties.sessionID,
|
||||
evt.properties.messageID,
|
||||
)
|
||||
|
||||
if (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation.state === "result"
|
||||
) {
|
||||
const metadata = message.metadata.tool[part.toolInvocation.toolCallId]
|
||||
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
|
||||
part.toolInvocation.toolName,
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
]
|
||||
printEvent(color, tool, metadata?.title || "Unknown")
|
||||
}
|
||||
|
||||
Bus.subscribe(Message.Event.PartUpdated, async (evt) => {
|
||||
if (evt.properties.sessionID !== session.id) return
|
||||
const part = evt.properties.part
|
||||
const message = await Session.getMessage(
|
||||
evt.properties.sessionID,
|
||||
evt.properties.messageID,
|
||||
)
|
||||
|
||||
if (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation.state === "result"
|
||||
) {
|
||||
const metadata =
|
||||
message.metadata.tool[part.toolInvocation.toolCallId]
|
||||
const [tool, color] = TOOL[part.toolInvocation.toolName] ?? [
|
||||
part.toolInvocation.toolName,
|
||||
UI.Style.TEXT_INFO_BOLD,
|
||||
]
|
||||
printEvent(color, tool, metadata?.title || "Unknown")
|
||||
if (part.type === "text") {
|
||||
if (part.text.includes("\n")) {
|
||||
UI.empty()
|
||||
UI.println(part.text)
|
||||
UI.empty()
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
if (part.text.includes("\n")) {
|
||||
UI.empty()
|
||||
UI.println(part.text)
|
||||
UI.empty()
|
||||
return
|
||||
}
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
})
|
||||
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (isPiped) {
|
||||
const match = result.parts.findLast((x) => x.type === "text")
|
||||
if (match) process.stdout.write(match.text)
|
||||
printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text)
|
||||
}
|
||||
UI.empty()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const result = await Session.chat({
|
||||
sessionID: session.id,
|
||||
providerID,
|
||||
modelID,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (isPiped) {
|
||||
const match = result.parts.findLast((x) => x.type === "text")
|
||||
if (match) process.stdout.write(match.text)
|
||||
}
|
||||
UI.empty()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
108
packages/opencode/src/cli/cmd/tui.ts
Normal file
108
packages/opencode/src/cli/cmd/tui.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { Global } from "../../global"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Server } from "../../server/server"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Installation } from "../../installation"
|
||||
import { Config } from "../../config/config"
|
||||
import { Bus } from "../../bus"
|
||||
import { AuthLoginCommand } from "./auth"
|
||||
|
||||
export const TuiCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
describe: "start opencode tui",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
} catch (e) {
|
||||
UI.error("Failed to change directory to " + cwd)
|
||||
return
|
||||
}
|
||||
const result = await bootstrap({ cwd }, async (app) => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
}
|
||||
|
||||
const server = Server.listen({
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
})
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = Bun.fileURLToPath(
|
||||
new URL("../../../../tui/cmd/opencode", import.meta.url),
|
||||
)
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
let binaryName = blob.name
|
||||
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
|
||||
binaryName += ".exe"
|
||||
}
|
||||
const binary = path.join(Global.Path.cache, "tui", binaryName)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, blob, { mode: 0o755 })
|
||||
await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cwd = process.cwd()
|
||||
cmd = [binary]
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
;(async () => {
|
||||
if (Installation.VERSION === "dev") return
|
||||
if (Installation.isSnapshot()) return
|
||||
const config = await Config.global()
|
||||
if (config.autoupdate === false) return
|
||||
const latest = await Installation.latest().catch(() => {})
|
||||
if (!latest) return
|
||||
if (Installation.VERSION === latest) return
|
||||
const method = await Installation.method()
|
||||
if (method === "unknown") return
|
||||
await Installation.upgrade(method, latest)
|
||||
.then(() => {
|
||||
Bus.publish(Installation.Event.Updated, { version: latest })
|
||||
})
|
||||
.catch(() => {})
|
||||
})()
|
||||
|
||||
await proc.exited
|
||||
server.stop()
|
||||
|
||||
return "done"
|
||||
})
|
||||
if (result === "done") break
|
||||
if (result === "needs_provider") {
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
await AuthLoginCommand.handler(args)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
|
@ -22,6 +22,7 @@ export namespace Config {
|
|||
}
|
||||
}
|
||||
log.info("loaded", result)
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
|
|
54
packages/opencode/src/config/hooks.ts
Normal file
54
packages/opencode/src/config/hooks.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { Session } from "../session"
|
||||
import { Log } from "../util/log"
|
||||
import { Config } from "./config"
|
||||
import path from "path"
|
||||
|
||||
export namespace ConfigHooks {
|
||||
const log = Log.create({ service: "config.hooks" })
|
||||
|
||||
export function init() {
|
||||
log.info("init")
|
||||
const app = App.info()
|
||||
|
||||
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||
const cfg = await Config.get()
|
||||
const ext = path.extname(payload.properties.file)
|
||||
for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) {
|
||||
log.info("file_edited", {
|
||||
file: payload.properties.file,
|
||||
command: item.command,
|
||||
})
|
||||
Bun.spawn({
|
||||
cmd: item.command.map((x) =>
|
||||
x.replace("$FILE", payload.properties.file),
|
||||
),
|
||||
env: item.environment,
|
||||
cwd: app.path.cwd,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Bus.subscribe(Session.Event.Idle, async () => {
|
||||
const cfg = await Config.get()
|
||||
if (cfg.experimental?.hook?.session_completed) {
|
||||
for (const item of cfg.experimental.hook.session_completed) {
|
||||
log.info("session_completed", {
|
||||
command: item.command,
|
||||
})
|
||||
Bun.spawn({
|
||||
cmd: item.command,
|
||||
cwd: App.info().path.cwd,
|
||||
env: item.environment,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
13
packages/opencode/src/file/index.ts
Normal file
13
packages/opencode/src/file/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod"
|
||||
import { Bus } from "../bus"
|
||||
|
||||
export namespace File {
|
||||
export const Event = {
|
||||
Edited: Bus.event(
|
||||
"file.edited",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { App } from "../../app/app"
|
||||
import { App } from "../app/app"
|
||||
|
||||
export namespace FileTimes {
|
||||
export namespace FileTime {
|
||||
export const state = App.state("tool.filetimes", () => {
|
||||
const read: {
|
||||
[sessionID: string]: {
|
|
@ -1,77 +1,68 @@
|
|||
import { App } from "../app/app"
|
||||
import { BunProc } from "../bun"
|
||||
import { Config } from "../config/config"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
const state = App.state("format", async () => {
|
||||
const hooks: Record<string, Hook[]> = {}
|
||||
for (const item of FORMATTERS) {
|
||||
if (await item.enabled()) {
|
||||
for (const ext of item.extensions) {
|
||||
const list = hooks[ext] ?? []
|
||||
list.push({
|
||||
command: item.command,
|
||||
environment: item.environment,
|
||||
})
|
||||
hooks[ext] = list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
for (const [file, items] of Object.entries(
|
||||
cfg.experimental?.hook?.file_edited ?? {},
|
||||
)) {
|
||||
for (const item of items) {
|
||||
const list = hooks[file] ?? []
|
||||
list.push({
|
||||
command: item.command,
|
||||
environment: item.environment,
|
||||
})
|
||||
hooks[file] = list
|
||||
}
|
||||
}
|
||||
const state = App.state("format", () => {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
|
||||
return {
|
||||
hooks,
|
||||
enabled,
|
||||
}
|
||||
})
|
||||
|
||||
export async function run(file: string) {
|
||||
log.info("formatting", { file })
|
||||
const { hooks } = await state()
|
||||
const ext = path.extname(file)
|
||||
const match = hooks[ext]
|
||||
if (!match) return
|
||||
|
||||
for (const item of match) {
|
||||
log.info("running", { command: item.command })
|
||||
const proc = Bun.spawn({
|
||||
cmd: item.command.map((x) => x.replace("$FILE", file)),
|
||||
cwd: App.info().path.cwd,
|
||||
env: item.environment,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
async function isEnabled(item: Definition) {
|
||||
const s = state()
|
||||
let status = s.enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
s.enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
interface Hook {
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
async function getFormatter(ext: string) {
|
||||
const result = []
|
||||
for (const item of FORMATTERS) {
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!isEnabled(item)) continue
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
interface Native {
|
||||
export function init() {
|
||||
log.info("init")
|
||||
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
const proc = Bun.spawn({
|
||||
cmd: item.command.map((x) => x.replace("$FILE", file)),
|
||||
cwd: App.info().path.cwd,
|
||||
env: item.environment,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface Definition {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
|
@ -79,7 +70,7 @@ export namespace Format {
|
|||
enabled(): Promise<boolean>
|
||||
}
|
||||
|
||||
const FORMATTERS: Native[] = [
|
||||
const FORMATTERS: Definition[] = [
|
||||
{
|
||||
name: "prettier",
|
||||
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
|
||||
|
@ -133,17 +124,9 @@ export namespace Format {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "mix format",
|
||||
name: "mix",
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [
|
||||
".ex",
|
||||
".exs",
|
||||
".eex",
|
||||
".heex",
|
||||
".leex",
|
||||
".neex",
|
||||
".sface",
|
||||
],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
|
|
|
@ -1,28 +1,19 @@
|
|||
import "zod-openapi/extend"
|
||||
import { App } from "./app/app"
|
||||
import { Server } from "./server/server"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Share } from "./share/share"
|
||||
import url from "node:url"
|
||||
import { Global } from "./global"
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { ScrapCommand } from "./cli/cmd/scrap"
|
||||
import { Log } from "./util/log"
|
||||
import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
|
||||
import { AuthCommand } from "./cli/cmd/auth"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { ModelsCommand } from "./cli/cmd/models"
|
||||
import { Provider } from "./provider/provider"
|
||||
import { UI } from "./cli/ui"
|
||||
import { Installation } from "./installation"
|
||||
import { Bus } from "./bus"
|
||||
import { Config } from "./config/config"
|
||||
import { NamedError } from "./util/error"
|
||||
import { FormatError } from "./cli/error"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { TuiCommand } from "./cli/cmd/tui"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
|
@ -55,103 +46,7 @@ const cli = yargs(hideBin(process.argv))
|
|||
})
|
||||
})
|
||||
.usage("\n" + UI.logo())
|
||||
.command({
|
||||
command: "$0 [project]",
|
||||
describe: "start opencode tui",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
} catch (e) {
|
||||
UI.error("Failed to change directory to " + cwd)
|
||||
return
|
||||
}
|
||||
const result = await App.provide({ cwd }, async (app) => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return "needs_provider"
|
||||
}
|
||||
|
||||
await Share.init()
|
||||
const server = Server.listen({
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
})
|
||||
|
||||
let cmd = ["go", "run", "./main.go"]
|
||||
let cwd = url.fileURLToPath(
|
||||
new URL("../../tui/cmd/opencode", import.meta.url),
|
||||
)
|
||||
if (Bun.embeddedFiles.length > 0) {
|
||||
const blob = Bun.embeddedFiles[0] as File
|
||||
let binaryName = blob.name
|
||||
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
|
||||
binaryName += ".exe"
|
||||
}
|
||||
const binary = path.join(Global.Path.cache, "tui", binaryName)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, blob, { mode: 0o755 })
|
||||
await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cwd = process.cwd()
|
||||
cmd = [binary]
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...cmd, ...process.argv.slice(2)],
|
||||
signal: cancel.signal,
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER: server.url.toString(),
|
||||
OPENCODE_APP_INFO: JSON.stringify(app),
|
||||
},
|
||||
onExit: () => {
|
||||
server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
;(async () => {
|
||||
if (Installation.VERSION === "dev") return
|
||||
if (Installation.isSnapshot()) return
|
||||
const config = await Config.global()
|
||||
if (config.autoupdate === false) return
|
||||
const latest = await Installation.latest().catch(() => {})
|
||||
if (!latest) return
|
||||
if (Installation.VERSION === latest) return
|
||||
const method = await Installation.method()
|
||||
if (method === "unknown") return
|
||||
await Installation.upgrade(method, latest)
|
||||
.then(() => {
|
||||
Bus.publish(Installation.Event.Updated, { version: latest })
|
||||
})
|
||||
.catch(() => {})
|
||||
})()
|
||||
|
||||
await proc.exited
|
||||
server.stop()
|
||||
|
||||
return "done"
|
||||
})
|
||||
if (result === "done") break
|
||||
if (result === "needs_provider") {
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
await AuthLoginCommand.handler(args)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
.command(TuiCommand)
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(ScrapCommand)
|
||||
|
|
|
@ -78,6 +78,12 @@ export namespace Session {
|
|||
info: Info,
|
||||
}),
|
||||
),
|
||||
Idle: Bus.event(
|
||||
"session.idle",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
}),
|
||||
),
|
||||
Error: Bus.event(
|
||||
"session.error",
|
||||
z.object({
|
||||
|
@ -854,18 +860,8 @@ export namespace Session {
|
|||
[Symbol.dispose]() {
|
||||
log.info("unlocking", { sessionID })
|
||||
state().pending.delete(sessionID)
|
||||
Config.get().then((cfg) => {
|
||||
if (cfg.experimental?.hook?.session_completed) {
|
||||
for (const item of cfg.experimental.hook.session_completed) {
|
||||
Bun.spawn({
|
||||
cmd: item.command,
|
||||
cwd: App.info().path.cwd,
|
||||
env: item.environment,
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
}
|
||||
}
|
||||
Bus.publish(Event.Idle, {
|
||||
sessionID,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { App } from "../app/app"
|
||||
import { Bus } from "../bus"
|
||||
import { Installation } from "../installation"
|
||||
import { Session } from "../session"
|
||||
|
@ -11,12 +10,6 @@ export namespace Share {
|
|||
let queue: Promise<void> = Promise.resolve()
|
||||
const pending = new Map<string, any>()
|
||||
|
||||
const state = App.state("share", async () => {
|
||||
Bus.subscribe(Storage.Event.Write, async (payload) => {
|
||||
await sync(payload.properties.key, payload.properties.content)
|
||||
})
|
||||
})
|
||||
|
||||
export async function sync(key: string, content: any) {
|
||||
const [root, ...splits] = key.split("/")
|
||||
if (root !== "session") return
|
||||
|
@ -52,8 +45,10 @@ export namespace Share {
|
|||
})
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
await state()
|
||||
export function init() {
|
||||
Bus.subscribe(Storage.Event.Write, async (payload) => {
|
||||
await sync(payload.properties.key, payload.properties.content)
|
||||
})
|
||||
}
|
||||
|
||||
export const URL =
|
||||
|
|
|
@ -5,13 +5,14 @@
|
|||
import { z } from "zod"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Permission } from "../permission"
|
||||
import DESCRIPTION from "./edit.txt"
|
||||
import { App } from "../app/app"
|
||||
import { Format } from "../format"
|
||||
import { File } from "../file"
|
||||
import { Bus } from "../bus"
|
||||
import { FileTime } from "../file/time"
|
||||
|
||||
export const EditTool = Tool.define({
|
||||
id: "edit",
|
||||
|
@ -60,7 +61,9 @@ export const EditTool = Tool.define({
|
|||
if (params.oldString === "") {
|
||||
contentNew = params.newString
|
||||
await Bun.write(filepath, params.newString)
|
||||
await Format.run(filepath)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filepath,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -69,7 +72,7 @@ export const EditTool = Tool.define({
|
|||
if (!stats) throw new Error(`File ${filepath} not found`)
|
||||
if (stats.isDirectory())
|
||||
throw new Error(`Path is a directory, not a file: ${filepath}`)
|
||||
await FileTimes.assert(ctx.sessionID, filepath)
|
||||
await FileTime.assert(ctx.sessionID, filepath)
|
||||
contentOld = await file.text()
|
||||
|
||||
contentNew = replace(
|
||||
|
@ -79,7 +82,9 @@ export const EditTool = Tool.define({
|
|||
params.replaceAll,
|
||||
)
|
||||
await file.write(contentNew)
|
||||
await Format.run(filepath)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filepath,
|
||||
})
|
||||
contentNew = await file.text()
|
||||
})()
|
||||
|
||||
|
@ -87,7 +92,7 @@ export const EditTool = Tool.define({
|
|||
createTwoFilesPatch(filepath, filepath, contentOld, contentNew),
|
||||
)
|
||||
|
||||
FileTimes.read(ctx.sessionID, filepath)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = ""
|
||||
await LSP.touchFile(filepath, true)
|
||||
|
|
|
@ -2,7 +2,7 @@ import { z } from "zod"
|
|||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import { FileTime } from "../file/time"
|
||||
import DESCRIPTION from "./patch.txt"
|
||||
|
||||
const PatchParams = z.object({
|
||||
|
@ -244,7 +244,7 @@ export const PatchTool = Tool.define({
|
|||
absPath = path.resolve(process.cwd(), absPath)
|
||||
}
|
||||
|
||||
await FileTimes.assert(ctx.sessionID, absPath)
|
||||
await FileTime.assert(ctx.sessionID, absPath)
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(absPath)
|
||||
|
@ -351,7 +351,7 @@ export const PatchTool = Tool.define({
|
|||
totalAdditions += additions
|
||||
totalRemovals += removals
|
||||
|
||||
FileTimes.read(ctx.sessionID, absPath)
|
||||
FileTime.read(ctx.sessionID, absPath)
|
||||
}
|
||||
|
||||
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
|
||||
|
|
|
@ -3,7 +3,7 @@ import * as fs from "fs"
|
|||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import { FileTime } from "../file/time"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { App } from "../app/app"
|
||||
|
||||
|
@ -90,7 +90,7 @@ export const ReadTool = Tool.define({
|
|||
|
||||
// just warms the lsp client
|
||||
await LSP.touchFile(filePath, true)
|
||||
FileTimes.read(ctx.sessionID, filePath)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
|
||||
return {
|
||||
output,
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { z } from "zod"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTimes } from "./util/file-times"
|
||||
import { LSP } from "../lsp"
|
||||
import { Permission } from "../permission"
|
||||
import DESCRIPTION from "./write.txt"
|
||||
import { App } from "../app/app"
|
||||
import { Format } from "../format"
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { FileTime } from "../file/time"
|
||||
|
||||
export const WriteTool = Tool.define({
|
||||
id: "write",
|
||||
|
@ -27,7 +28,7 @@ export const WriteTool = Tool.define({
|
|||
|
||||
const file = Bun.file(filepath)
|
||||
const exists = await file.exists()
|
||||
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
|
||||
if (exists) await FileTime.assert(ctx.sessionID, filepath)
|
||||
|
||||
await Permission.ask({
|
||||
id: "write",
|
||||
|
@ -43,8 +44,10 @@ export const WriteTool = Tool.define({
|
|||
})
|
||||
|
||||
await Bun.write(filepath, params.content)
|
||||
await Format.run(filepath)
|
||||
FileTimes.read(ctx.sessionID, filepath)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filepath,
|
||||
})
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = ""
|
||||
await LSP.touchFile(filepath, true)
|
||||
|
|
|
@ -1,91 +0,0 @@
|
|||
import path from "path"
|
||||
import { readdir } from "fs/promises"
|
||||
|
||||
export namespace Project {
|
||||
export async function getName(rootPath: string): Promise<string> {
|
||||
try {
|
||||
const packageJsonPath = path.join(rootPath, "package.json")
|
||||
const packageJson = await Bun.file(packageJsonPath).json()
|
||||
if (packageJson.name && typeof packageJson.name === "string") {
|
||||
return packageJson.name
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const cargoTomlPath = path.join(rootPath, "Cargo.toml")
|
||||
const cargoToml = await Bun.file(cargoTomlPath).text()
|
||||
const nameMatch = cargoToml.match(/^\s*name\s*=\s*"([^"]+)"/m)
|
||||
if (nameMatch?.[1]) {
|
||||
return nameMatch[1]
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const pyprojectPath = path.join(rootPath, "pyproject.toml")
|
||||
const pyproject = await Bun.file(pyprojectPath).text()
|
||||
const nameMatch = pyproject.match(/^\s*name\s*=\s*"([^"]+)"/m)
|
||||
if (nameMatch?.[1]) {
|
||||
return nameMatch[1]
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const goModPath = path.join(rootPath, "go.mod")
|
||||
const goMod = await Bun.file(goModPath).text()
|
||||
const moduleMatch = goMod.match(/^module\s+(.+)$/m)
|
||||
if (moduleMatch?.[1]) {
|
||||
// Extract just the last part of the module path
|
||||
const parts = moduleMatch[1].trim().split("/")
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const composerPath = path.join(rootPath, "composer.json")
|
||||
const composer = await Bun.file(composerPath).json()
|
||||
if (composer.name && typeof composer.name === "string") {
|
||||
// Composer names are usually vendor/package, extract the package part
|
||||
const parts = composer.name.split("/")
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const pomPath = path.join(rootPath, "pom.xml")
|
||||
const pom = await Bun.file(pomPath).text()
|
||||
const artifactIdMatch = pom.match(/<artifactId>([^<]+)<\/artifactId>/)
|
||||
if (artifactIdMatch?.[1]) {
|
||||
return artifactIdMatch[1]
|
||||
}
|
||||
} catch {}
|
||||
|
||||
for (const gradleFile of ["build.gradle", "build.gradle.kts"]) {
|
||||
try {
|
||||
const gradlePath = path.join(rootPath, gradleFile)
|
||||
await Bun.file(gradlePath).text() // Check if gradle file exists
|
||||
// Look for rootProject.name in settings.gradle
|
||||
const settingsPath = path.join(rootPath, "settings.gradle")
|
||||
const settings = await Bun.file(settingsPath).text()
|
||||
const nameMatch = settings.match(
|
||||
/rootProject\.name\s*=\s*['"]([^'"]+)['"]/,
|
||||
)
|
||||
if (nameMatch?.[1]) {
|
||||
return nameMatch[1]
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const dotnetExtensions = [".csproj", ".fsproj", ".vbproj"]
|
||||
try {
|
||||
const files = await readdir(rootPath)
|
||||
for (const file of files) {
|
||||
if (dotnetExtensions.some((ext) => file.endsWith(ext))) {
|
||||
// Use the filename without extension as project name
|
||||
return path.basename(file, path.extname(file))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return path.basename(rootPath)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue