diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 96d92c50b2..e9d25911ac 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -18,6 +18,7 @@ export function runTui( const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { + const runFork = Effect.runForkWith(yield* Effect.context()) const options = { baseUrl: transport.url, headers: transport.headers } const api = OpenCode.make(options) const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( @@ -41,6 +42,17 @@ export function runTui( reload, args, config, + log: (level, message, tags) => { + const effect = + level === "debug" + ? Effect.logDebug(message, tags) + : level === "warn" + ? Effect.logWarning(message, tags) + : level === "error" + ? Effect.logError(message, tags) + : Effect.logInfo(message, tags) + runFork(effect) + }, pluginHost: { async start(input) { disposeSlots = await loadBuiltinPlugins(input.api, input.runtime) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ae05ddef7d..4533f9b378 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -119,7 +119,17 @@ export class Directory extends Schema.Class("Config.Directory")({ path: AbsolutePath, }) {} -export type Entry = Document | Directory +export class AgentsDirectory extends Schema.Class("Config.AgentsDirectory")({ + type: Schema.Literal("agents"), + path: AbsolutePath, +}) {} + +export class ClaudeDirectory extends Schema.Class("Config.ClaudeDirectory")({ + type: Schema.Literal("claude"), + path: AbsolutePath, +}) {} + +export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory export function latest(entries: readonly Entry[], key: K): Info[K] | undefined { return entries @@ -176,16 +186,37 @@ const layer = Layer.effect( const discover = Effect.fn("Config.discover")(function* () { const globalDirectory = AbsolutePath.make(global.config) + const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) + const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) const discovered = locationIsGlobal ? [] : yield* fs .up({ - targets: [".opencode", ...names.toReversed()], + targets: [".opencode", ".claude", ".agents", ...names.toReversed()], start: location.directory, stop: location.project.directory, }) - .pipe(Effect.orDie) + .pipe(Effect.orDie) + + // We load certain files from a few other folders in the ecosystem + const claude = [ + ...((yield* fs.isDir(globalClaudeDirectory)) + ? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })] + : []), + ...discovered + .filter((item) => path.basename(item) === ".claude") + .map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })), + ] + const agents = [ + ...((yield* fs.isDir(globalAgentsDirectory)) + ? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })] + : []), + ...discovered + .filter((item) => path.basename(item) === ".agents") + .map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })), + ] + const directories = [ globalDirectory, ...discovered @@ -193,15 +224,18 @@ const layer = Layer.effect( .toReversed() .map((directory) => AbsolutePath.make(directory)), ] - const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() + const directPaths = discovered + .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))) + .toReversed() const direct = yield* Effect.forEach(directPaths, loadFile).pipe( Effect.orDie, Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), ) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) return { - entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], - directories, + entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()], + directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)], files: directPaths, } }) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 965f738ecc..0be04bf85a 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -51,6 +51,7 @@ export const Plugin = define({ const load = Effect.fn("ConfigAgentPlugin.load")(function* () { return yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([entry]) + if (entry.type !== "directory") return Effect.succeed([]) return Effect.gen(function* () { const files = yield* discover(fs, entry.path) return yield* Effect.forEach(files, (file) => diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index b294ab15b9..744f58f7a7 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -19,6 +19,7 @@ export const Plugin = define({ const load = Effect.fn("ConfigCommandPlugin.load")(function* () { return yield* Effect.forEach(yield* config.entries(), (entry) => { if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + if (entry.type !== "directory") return Effect.succeed([]) return loadDirectory(fs, entry.path).pipe( Effect.map((commands) => [ { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 84546b901c..de2f28ac9c 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -17,8 +17,18 @@ export const Plugin = define({ const location = yield* Location.Service const loaded = { entries: yield* config.entries() } yield* ctx.skill.transform((draft) => { + const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : [])) + const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : [])) const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + for (const directory of [...claude, ...agents]) { + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join(directory, "skills")), + }), + ) + } for (const directory of directories) { draft.source( SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 439b77fd36..9e8726c4e4 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -92,6 +92,7 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* ( }), ) } + if (entry.type !== "directory") return Effect.succeed([]) return fs .glob("{plugin,plugins}/*.{ts,js}", { cwd: entry.path, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 85db0a6bf3..4df256c86b 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -44,7 +44,7 @@ function testLayer( ) return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ [Location.node, locationLayer], - [Global.node, Global.layerWith({ config: globalDirectory })], + [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })], ...(watcher ? ([[Watcher.node, watcher]] as const) : []), ]) } @@ -112,6 +112,7 @@ describe("Config", () => { info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }), }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }), + new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }), new Config.Document({ type: "document", info: new Config.Info({}) }), new Config.Document({ type: "document", @@ -848,11 +849,19 @@ describe("Config", () => { const root = path.join(tmp.path, "repo") const parent = path.join(root, "packages") const directory = path.join(parent, "app") + const globalAgents = path.join(global, "home", ".agents") + const globalClaude = path.join(global, "home", ".claude") return Effect.gen(function* () { yield* Effect.promise(async () => { await fs.mkdir(global, { recursive: true }) + await fs.mkdir(globalAgents, { recursive: true }) + await fs.mkdir(globalClaude, { recursive: true }) await fs.mkdir(directory, { recursive: true }) + await fs.mkdir(path.join(root, ".agents"), { recursive: true }) + await fs.mkdir(path.join(root, ".claude"), { recursive: true }) await fs.mkdir(path.join(root, ".opencode"), { recursive: true }) + await fs.mkdir(path.join(directory, ".agents"), { recursive: true }) + await fs.mkdir(path.join(directory, ".claude"), { recursive: true }) await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) await Promise.all([ fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })), @@ -878,6 +887,16 @@ describe("Config", () => { AbsolutePath.make(path.join(root, ".opencode")), AbsolutePath.make(path.join(directory, ".opencode")), ]) + expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([ + AbsolutePath.make(globalAgents), + AbsolutePath.make(path.join(directory, ".agents")), + AbsolutePath.make(path.join(root, ".agents")), + ]) + expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([ + AbsolutePath.make(globalClaude), + AbsolutePath.make(path.join(directory, ".claude")), + AbsolutePath.make(path.join(root, ".claude")), + ]) expect(documents.map((document) => document.info.$schema)).toEqual([ "global", "root", @@ -887,6 +906,12 @@ describe("Config", () => { "directory-dot", ]) expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([ + AbsolutePath.make(globalClaude), + AbsolutePath.make(path.join(directory, ".claude")), + AbsolutePath.make(path.join(root, ".claude")), + AbsolutePath.make(globalAgents), + AbsolutePath.make(path.join(directory, ".agents")), + AbsolutePath.make(path.join(root, ".agents")), "global", AbsolutePath.make(global), "root", diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 28ae7956dc..8b5b0bdf62 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -46,6 +46,8 @@ describe("ConfigSkillPlugin.Plugin", () => { Config.Service.of({ entries: () => Effect.succeed([ + new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }), + new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }), new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }), new Config.Document({ type: "document", @@ -59,6 +61,14 @@ describe("ConfigSkillPlugin.Plugin", () => { ) expect(sources).toEqual([ + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join("/repo/.claude", "skills")), + }), + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join("/repo/.agents", "skills")), + }), SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")), diff --git a/packages/tui/package.json b/packages/tui/package.json index 9eb3dfa9cc..0924b5cfef 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -17,6 +17,7 @@ "./context/epilogue": "./src/context/epilogue.tsx", "./context/exit": "./src/context/exit.tsx", "./context/kv": "./src/context/kv.tsx", + "./context/log": "./src/context/log.tsx", "./context/project": "./src/context/project.tsx", "./context/runtime": "./src/context/runtime.tsx", "./context/sdk": "./src/context/sdk.tsx", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index cbb398a55d..c5535ad32d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -6,6 +6,7 @@ import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { ClipboardProvider, useClipboard } from "./context/clipboard" +import { LogProvider, useLog, type LogSink } from "./context/log" import { ExitProvider, useExit } from "./context/exit" import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" @@ -149,6 +150,7 @@ export type TuiInput = { config: TuiConfig.Resolved onSnapshot?: () => Promise pluginHost: TuiPluginHost + log?: LogSink } function errorMessage(error: unknown) { @@ -184,6 +186,7 @@ function isVersionGreater(left: string, right: string) { } export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + const log = input.log ?? (() => {}) const global = yield* Global.Service const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( @@ -230,7 +233,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { try { await input.pluginHost.dispose() } catch (error) { - console.error("Failed to dispose TUI plugins", error) + log("error", "Failed to dispose TUI plugins", { error }) } }), ) @@ -252,112 +255,116 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { await render(() => { return ( - { - if (renderer.isDestroyed) return - exit.reason = reason - destroyRenderer(renderer) - }} - > - (exit.epilogue = value)}> - }> - + { + if (renderer.isDestroyed) return + exit.reason = reason + destroyRenderer(renderer) + }} + > + (exit.epilogue = value)}> + } > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) }, renderer) }) @@ -374,6 +381,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPluginHost }) { + const log = useLog({ component: "app" }) const startup = useTuiStartup() const tuiConfig = useTuiConfig() const route = useRoute() @@ -454,7 +462,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dispose: () => attention.dispose(), }) .catch((error) => { - console.error("Failed to load TUI plugins", error) + log.error("Failed to load TUI plugins", { error }) }) .finally(() => { setReady(true) @@ -816,8 +824,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) // reload resolves once the replacement service is healthy; the // event stream reattaches through the reconnect loop. - await sdk - .reload!() + await sdk.reload!() .then(() => toast.show({ variant: "success", message: "Server reloaded" })) .catch(toast.error) }, @@ -1091,7 +1098,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) event.on("installation.update-available", async (evt) => { - console.log("installation.update-available", evt) const version = evt.data.version const skipped = kv.get("skipped_version") diff --git a/packages/tui/src/context/log.tsx b/packages/tui/src/context/log.tsx new file mode 100644 index 0000000000..75a5f0cf65 --- /dev/null +++ b/packages/tui/src/context/log.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, type ParentProps } from "solid-js" + +export type LogLevel = "debug" | "info" | "warn" | "error" +export type LogTags = Readonly> +export type LogSink = (level: LogLevel, message: string, tags: LogTags) => void + +const LogContext = createContext() + +export function LogProvider(props: ParentProps<{ log: LogSink }>) { + return {props.children} +} + +export function useLog(tags: LogTags = {}) { + const sink = useContext(LogContext) + if (!sink) throw new Error("Log context must be used within a LogProvider") + + const write = (level: LogLevel, message: string, extra: LogTags = {}) => sink(level, message, { ...tags, ...extra }) + return { + debug: (message: string, extra?: LogTags) => write("debug", message, extra), + info: (message: string, extra?: LogTags) => write("info", message, extra), + warn: (message: string, extra?: LogTags) => write("warn", message, extra), + error: (message: string, extra?: LogTags) => write("error", message, extra), + } +} diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 728ced3ed4..d481ab3f85 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" +import { useLog } from "./log" export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting" @@ -19,6 +20,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Stops and starts the managed service; present only in service mode. reload?: () => Promise }) => { + const log = useLog() const abort = new AbortController() let client = props.client let api = props.api @@ -64,7 +66,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ return connection.signal.reason instanceof Error ? connection.signal.reason : new Error("Event stream disconnected") - if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected") + if (first.value.type !== "server.connected") + return new Error("Event stream did not start with server.connected") clearTimeout(timeout) attempt = 0 events.emit(first.value.type, first.value) @@ -74,6 +77,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const event = await iterator.next() if (abort.signal.aborted || controller.signal.aborted) return if (event.done) return new Error("Event stream disconnected") + if ("durable" in event.value) + log.info("event", { + type: event.value.type, + aggregateID: event.value.durable.aggregateID, + seq: event.value.durable.seq, + }) events.emit(event.value.type, event.value) } })() diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index 722c2baf51..e1350bcc2b 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1 +1,2 @@ export { run, type TuiInput } from "./app" +export { LogProvider, useLog, type LogLevel, type LogSink, type LogTags } from "./context/log" diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index c4302e2790..626c8fd03e 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -34,6 +34,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: {}, + log: () => {}, pluginHost: { async start() { started() @@ -115,6 +116,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: { sessionID: "dummy" }, + log: () => {}, pluginHost: { async start(input) { api = input.api diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 7a4465c927..6c9aa750b9 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -9,6 +9,7 @@ import { SDKProvider, useSDK } from "../../../src/context/sdk" import { useEvent } from "../../../src/context/event" import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" +import type { LogSink } from "../../../src/context/log" const projectID = "proj_test" @@ -49,7 +50,7 @@ function update(version: string): V2Event { } } -async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) { +async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>, log?: LogSink) { const events = createEventStream() const calls = createFetch(undefined, events) const seen: V2Event[] = [] @@ -62,7 +63,7 @@ async function mount(discover?: () => Promise<{ client: OpencodeClient; api: Ope }) const app = await testRender(() => ( - + { + test("logs only durable events", async () => { + const logs: Array<{ message: string; tags: Readonly> }> = [] + const { app, emit, seen } = await mount(undefined, (_level, message, tags) => logs.push({ message, tags })) + const durable = event( + { + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { sessionID: "ses_test", title: "Renamed" }, + }, + { directory: "/tmp/project" }, + ) + + try { + emit(vcs("main")) + emit(durable) + await wait(() => seen.length === 2 && logs.length === 1) + + expect(logs).toEqual([ + { + message: "event", + tags: { type: "session.renamed", aggregateID: "ses_test", seq: 1 }, + }, + ]) + } finally { + app.renderer.destroy() + } + }) + test("delivers events for the current project", async () => { const { app, emit, seen, workspaces } = await mount() diff --git a/packages/tui/test/fixture/tui-environment.tsx b/packages/tui/test/fixture/tui-environment.tsx index 543332ba18..e2ef9ba774 100644 --- a/packages/tui/test/fixture/tui-environment.tsx +++ b/packages/tui/test/fixture/tui-environment.tsx @@ -6,27 +6,31 @@ import { type TuiPaths, } from "../../src/context/runtime" import type { ParentProps } from "solid-js" +import { LogProvider, type LogSink } from "../../src/context/log" export function TestTuiContexts( props: ParentProps<{ cwd?: string directory?: string paths?: Partial + log?: LogSink }>, ) { return ( - - - {props.children} - - + {})}> + + + {props.children} + + + ) }