Compare commits

..

13 Commits

Author SHA1 Message Date
Kit Langton a58b3e9b37 refactor(core): generate static imports for the migration manifest 2026-08-10 23:26:36 -04:00
Kit Langton 518af92c5b refactor(core): move the models.dev catalog cache from disk to KV (#41649) 2026-08-10 22:40:41 -04:00
Kit Langton 5d8c48718b refactor(core): move instruction discovery to the config side (#41629) 2026-08-10 22:32:58 -04:00
Kit Langton 41c5853739 refactor: route Global path consumers through the service (#41632) 2026-08-10 21:37:09 -04:00
Kit Langton 8d989f6829 fix(tui): collapse execute child details (#41624) 2026-08-10 21:20:51 -04:00
Kit Langton 753fa4cd60 fix(core): isolate config plugin tests from ambient user config (#41631) 2026-08-11 01:16:08 +00:00
opencode-agent[bot] d99a268ea5 chore: generate 2026-08-11 00:39:33 +00:00
Kit Langton 1113adfd5e refactor(core): skill service stores values, config plugin owns the filesystem (#41622) 2026-08-10 20:38:08 -04:00
Kit Langton 6afff5a55f fix(util): no filesystem side effects at global module load (#41619) 2026-08-10 20:38:04 -04:00
Kit Langton 71f5e4189d refactor(core): move plugin discovery and watching to the config side (#41618) 2026-08-10 20:15:08 -04:00
Kit Langton b49bad9a86 fix(merman): remove sequence lifeline fade (#41623) 2026-08-10 19:48:28 -04:00
Kit Langton 2580f880a8 feat(merman): refine sequence diagram styling (#41617) 2026-08-10 19:12:46 -04:00
Kit Langton 9d34029cd9 fix(core): runtime-neutral legacy credential import (#41607) 2026-08-10 18:35:52 -04:00
85 changed files with 2699 additions and 1804 deletions
@@ -4,6 +4,7 @@ import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { Config } from "../../config"
import { resolve } from "@opencode-ai/tui/config"
import { Global } from "@opencode-ai/util/global"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
@@ -16,6 +17,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
mismatch: "replace",
})
const config = yield* Config.Service
const global = yield* Global.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
@@ -39,6 +41,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
config: {
update: (update) => runServicePromise(config.update(update)),
},
paths: { home: global.home, state: global.state, log: global.log },
}),
)
}),
+2 -7
View File
@@ -1,6 +1,5 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Global } from "@opencode-ai/util/global"
import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
@@ -129,13 +128,9 @@ export async function usingInteractiveStdin<T>(
export function createMiniHost(input: {
terminal: InteractiveStdin
directory: string
paths?: { home: string; state: string; log: string }
paths: { home: string; state: string; log: string }
}): MiniHost {
const paths = input.paths ?? {
home: Global.Path.home,
state: Global.Path.state,
log: Global.Path.log,
}
const paths = input.paths
const diagnostics = {
pid: process.pid,
cwd: input.directory,
+2 -1
View File
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"]
paths: { home: string; state: string; log: string }
}
type Model = MiniFrontendInput["model"]
@@ -104,7 +105,7 @@ export async function runMini(input: MiniCommandInput) {
}))
const frontend = await frontendTask
return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory }),
host: createMiniHost({ terminal, directory, paths: input.paths }),
sdk,
directory,
target: resolveTarget,
+2 -1
View File
@@ -39,7 +39,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
+4 -5
View File
@@ -194,11 +194,10 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
export const migrations = [
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
] satisfies DatabaseMigration.Migration[]
`
}
+11 -2
View File
@@ -11,6 +11,7 @@ import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config"
import { Location } from "./location"
import { ShellSelect } from "./shell/select"
import { Global } from "@opencode-ai/util/global"
export const Info = Command.Info
export type Info = Command.Info
@@ -61,6 +62,7 @@ export const layer = (options?: ShellSelect.Options) =>
const processes = yield* AppProcess.Service
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
@@ -111,6 +113,7 @@ export const layer = (options?: ShellSelect.Options) =>
location,
processes,
shell: options,
bin: global.bin,
})
const prompt = (yield* mcp.prompts()).find(
@@ -164,6 +167,7 @@ function evaluateTemplate(
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
},
) {
return Effect.gen(function* () {
@@ -197,11 +201,16 @@ const evaluateShell = Effect.fnUntraced(function* (
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
readonly bin: string
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
const shell = ShellSelect.preferred(
Config.latest(yield* services.config.entries(), "shell"),
services.shell,
services.bin,
)
const outputs = yield* Effect.forEach(
matches,
(match) => {
@@ -262,7 +271,7 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
})
}
@@ -0,0 +1,122 @@
export * as ConfigInstructionPlugin from "./instruction"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { dirname, join } from "path"
import { Effect, PubSub, Semaphore, Stream } from "effect"
import { Watcher } from "../../filesystem/watcher"
import { InstructionDiscovery } from "../../instruction-discovery"
import { Instructions } from "../../instructions/index"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema"
type Loaded =
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
| { readonly type: "unavailable" }
export const Plugin = define({
id: "opencode.config.instruction",
effect: Effect.fn(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const project = discovery.project && FSUtil.contains(stop, start)
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
const candidates = [
globalFile,
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
]
for (const path of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path, type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
const content = yield* fs.readFileStringSafe(path)
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
const file = yield* read(globalFile)
return file ? [file] : []
})
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
if (!project) return []
const discovered = new Set(
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
)
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
if (files.some((file) => file === undefined)) return Instructions.unavailable
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
})
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load instruction source", { source, cause }).pipe(
Effect.as(Instructions.unavailable),
),
),
)
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* discovery.transform((draft) => {
if (loaded.current.type === "unavailable") {
draft.unavailable()
return
}
for (const file of loaded.current.files) draft.add(file)
})
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
Effect.asVoid,
),
),
)
}),
})
function ancestorDirectories(start: string, stop: string): string[] {
if (start === stop) return [start]
return [start, ...ancestorDirectories(dirname(start), stop)]
}
@@ -0,0 +1,57 @@
export * as SkillFile from "./skill-file"
import path from "path"
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
import { ConfigMarkdown } from "../markdown"
import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill"
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
export type ParseResult =
| { readonly _tag: "Parsed"; readonly skill: Skill.Info }
| { readonly _tag: "Skipped"; readonly reason: "markdown" }
| { readonly _tag: "Skipped"; readonly reason: "frontmatter"; readonly issue: SchemaIssue.Issue }
const metadataBoolean = (metadata: unknown, key: string) => {
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const value = Reflect.get(metadata, key)
if (typeof value === "boolean") return value
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (normalized === "true") return true
if (normalized === "false") return false
return undefined
}
export function parse(directory: string, filepath: string, content: string): ParseResult {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return { _tag: "Skipped", reason: "markdown" }
const decoded = decodeFrontmatter(markdown.data)
if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure }
const frontmatter = decoded.success
const id =
path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath))
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
return {
_tag: "Parsed",
skill: {
id: Skill.ID.make(id),
name: Skill.Name.make(frontmatter.name ?? id),
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
...(slash === undefined ? {} : { slash }),
...(autoinvoke === undefined ? {} : { autoinvoke }),
location: AbsolutePath.make(filepath),
content: markdown.content,
},
}
}
+149 -22
View File
@@ -1,64 +1,191 @@
export * as ConfigSkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import path from "path"
import { Effect, Stream } from "effect"
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { Config } from "../../config"
import { Watcher } from "../../filesystem/watcher"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location"
import { SkillDiscovery } from "../../skill/discovery"
import { SkillFile } from "./skill-file"
type Source = Skill.DirectorySource | Skill.UrlSource
export const Plugin = define({
id: "opencode.config.skill",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => {
const watcher = yield* Watcher.Service
const loaded: { entries: Entry[]; skills: Skill.Info[] } = {
entries: yield* config.entries(),
skills: [],
}
const watches = yield* FiberMap.make<string>()
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{ onlyIfMissing: true, startImmediately: true },
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn(
"ConfigSkillPlugin.watchDirectory",
)(function* (directory: string) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) yield* watch(target, "file")
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const sources = () => {
const result: Source[] = []
const add = (source: Source) => {
if (result.some((item) => Skill.Source.equals(item, source))) return
result.push(source)
}
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(
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
}
for (const directory of directories) {
draft.source(
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
add(Skill.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
add(
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
return result
}
const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) {
const directories =
source.type === "directory"
? [source.path]
: yield* discovery.pull(source.url).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load skill source", {
source: Skill.Source.key(source),
cause,
}).pipe(Effect.as([] as AbsolutePath[])),
),
)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const skills: Skill.Info[] = []
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const parsed = SkillFile.parse(directory, filepath, content)
if (parsed._tag === "Skipped") {
yield* Effect.logDebug("skill file skipped", {
filepath,
reason: parsed.reason,
...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}),
})
continue
}
skills.push(parsed.skill)
}
}
yield* Effect.logDebug("skill source loaded", {
source: Skill.Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
})
return skills
})
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* ctx.skill.transform((draft) => {
for (const skill of loaded.skills) draft.add(skill)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(refresh()),
Effect.andThen(ctx.skill.reload()),
),
),
+179
View File
@@ -0,0 +1,179 @@
export * as ConfigPluginSource from "./source"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config"
import { Watcher } from "../../filesystem/watcher"
import { Location } from "../../location"
export type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
export interface Interface {
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
readonly changes: () => Stream.Stream<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPluginSource") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* (
entries: readonly Entry[],
operations: readonly Operation[],
) {
for (const operation of operations) {
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
if (watched.has(operation.target)) continue
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
// Directory targets can't hot-reload (their stat mtime ignores edits
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
),
Effect.forkScoped({ startImmediately: true }),
)
}
})
return Service.of({
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
const entries = yield* config.entries()
const operations = yield* scan(fs, location, entries)
yield* watchConfiguredSources(entries, operations)
return operations
}),
changes: () =>
Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.map(() => undefined),
),
Stream.fromPubSub(configuredChanges),
),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
})
export const empty = makeLocationNode({
service: Service,
layer: Layer.succeed(
Service,
Service.of({
operations: () => Effect.succeed([]),
changes: () => Stream.never,
}),
),
deps: [],
})
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
fs: FSUtil.Interface,
location: Location.Interface,
entries: readonly Entry[],
) {
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return { ...operation, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
+10 -7
View File
@@ -40,16 +40,19 @@ const databaseLayer = Layer.effect(
)
export function layer(options: Options = { path: ":memory:" }) {
return Layer.suspend(() => {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
return provide(join(Global.Path.data, filename))
})
return Layer.unwrap(
Effect.gen(function* () {
const global = yield* Global.Service
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
return provide(join(global.data, filename))
}),
)
}
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
export const node = configured({ path: ":memory:" })
+84 -45
View File
@@ -1,47 +1,86 @@
import type { DatabaseMigration } from "./migration"
import m00 from "./migration/20260127222353_familiar_lady_ursula"
import m01 from "./migration/20260211171708_add_project_commands"
import m02 from "./migration/20260213144116_wakeful_the_professor"
import m03 from "./migration/20260225215848_workspace"
import m04 from "./migration/20260227213759_add_session_workspace_id"
import m05 from "./migration/20260228203230_blue_harpoon"
import m06 from "./migration/20260303231226_add_workspace_fields"
import m07 from "./migration/20260309230000_move_org_to_state"
import m08 from "./migration/20260312043431_session_message_cursor"
import m09 from "./migration/20260323234822_events"
import m10 from "./migration/20260410174513_workspace-name"
import m11 from "./migration/20260413175956_chief_energizer"
import m12 from "./migration/20260423070820_add_icon_url_override"
import m13 from "./migration/20260427172553_slow_nightmare"
import m14 from "./migration/20260428004200_add_session_path"
import m15 from "./migration/20260501142318_next_venus"
import m16 from "./migration/20260504145000_add_sync_owner"
import m17 from "./migration/20260507164347_add_workspace_time"
import m18 from "./migration/20260510033149_session_usage"
import m19 from "./migration/20260511000411_data_migration_state"
import m20 from "./migration/20260511173437_session-metadata"
import m21 from "./migration/20260601010001_normalize_storage_paths"
import m22 from "./migration/20260601202201_amazing_prowler"
import m23 from "./migration/20260602002951_lowly_union_jack"
import m24 from "./migration/20260602182828_add_project_directories"
import m25 from "./migration/20260603001617_session_message_projection_indexes"
import m26 from "./migration/20260603040000_session_message_projection_order"
import m27 from "./migration/20260603141458_session_input_inbox"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
import m29 from "./migration/20260604172448_event_sourced_session_input"
import m30 from "./migration/20260605003541_add_session_context_snapshot"
import m31 from "./migration/20260605042240_add_context_epoch_agent"
import m32 from "./migration/20260611035744_credential"
import m33 from "./migration/20260611192811_lush_chimera"
import m34 from "./migration/20260612174303_project_dir_strategy"
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
import m36 from "./migration/20260622170816_reset_v2_session_state"
import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
import("./migration/20260213144116_wakeful_the_professor"),
import("./migration/20260225215848_workspace"),
import("./migration/20260227213759_add_session_workspace_id"),
import("./migration/20260228203230_blue_harpoon"),
import("./migration/20260303231226_add_workspace_fields"),
import("./migration/20260309230000_move_org_to_state"),
import("./migration/20260312043431_session_message_cursor"),
import("./migration/20260323234822_events"),
import("./migration/20260410174513_workspace-name"),
import("./migration/20260413175956_chief_energizer"),
import("./migration/20260423070820_add_icon_url_override"),
import("./migration/20260427172553_slow_nightmare"),
import("./migration/20260428004200_add_session_path"),
import("./migration/20260501142318_next_venus"),
import("./migration/20260504145000_add_sync_owner"),
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
import("./migration/20260602182828_add_project_directories"),
import("./migration/20260603001617_session_message_projection_indexes"),
import("./migration/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260808023530_workspace_domain"),
])
).map((module) => module.default)
export const migrations = [
m00,
m01,
m02,
m03,
m04,
m05,
m06,
m07,
m08,
m09,
m10,
m11,
m12,
m13,
m14,
m15,
m16,
m17,
m18,
m19,
m20,
m21,
m22,
m23,
m24,
m25,
m26,
m27,
m28,
m29,
m30,
m31,
m32,
m33,
m34,
m35,
m36,
m37,
m38,
m39,
m40,
] satisfies DatabaseMigration.Migration[]
+2 -1
View File
@@ -5,6 +5,7 @@ import { Effect, Semaphore } from "effect"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
import schema from "./schema.gen"
import { Global } from "@opencode-ai/util/global"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
@@ -13,7 +14,7 @@ const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
foreignKeys?: boolean
up: (tx: Transaction) => Effect.Effect<void, unknown>
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
}
export function apply(db: Database) {
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
@@ -33,7 +34,10 @@ const wellKnownSourcesKey = "wellknown:sources"
const migration: DatabaseMigration.Migration = {
id: "20260805200742_import_legacy_credentials",
up(tx) {
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
return Effect.gen(function* () {
const global = yield* Global.Service
return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
})
},
}
@@ -41,9 +45,9 @@ export default migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
const file = Bun.file(filepath)
if (!(yield* Effect.promise(() => file.exists()))) return
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
if (content === undefined) return
const input = Option.getOrUndefined(decodeJson(content))
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
}
+7 -6
View File
@@ -467,10 +467,11 @@ function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service> {
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
@@ -478,7 +479,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
@@ -492,7 +493,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options))
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
@@ -502,7 +503,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options), (completed) => {
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
@@ -621,10 +622,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
)
}
function nextPath(options: Options) {
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(Global.Path.data, "opencode-next.db")
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
@@ -0,0 +1,71 @@
export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document } from "@opencode-ai/schema/config"
import path from "path"
import { Config } from "../config"
import { Bus } from "../bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
})
+4 -1
View File
@@ -7,6 +7,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config"
import { Location } from "./location"
import { make, type Info } from "./formatter/builtins"
@@ -25,6 +26,7 @@ const layer = Layer.effect(
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const global = yield* Global.Service
const commands = new Map<string, string[] | false>()
let formatters: Info[] = []
@@ -42,6 +44,7 @@ const layer = Layer.effect(
fs,
npm,
processes,
bin: global.bin,
})
formatters = builtIns
if (configured === true) return
@@ -122,5 +125,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
})
+34 -26
View File
@@ -18,8 +18,10 @@ export function make(input: {
readonly fs: FSUtil.Interface
readonly npm: Npm.Interface
readonly processes: AppProcess.Interface
readonly bin: string
}) {
const disabled = false as const
const findExecutable = (name: string) => which(name, undefined, input.bin)
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
const commandOutput = (command: string[]) =>
@@ -37,7 +39,7 @@ export function make(input: {
name: "gofmt",
extensions: [".go"],
enabled: Effect.sync(() => {
const match = which("gofmt")
const match = findExecutable("gofmt")
return match ? [match, "-w", "$FILE"] : disabled
}),
}
@@ -46,7 +48,7 @@ export function make(input: {
name: "mix",
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
enabled: Effect.sync(() => {
const match = which("mix")
const match = findExecutable("mix")
return match ? [match, "format", "$FILE"] : disabled
}),
}
@@ -149,7 +151,7 @@ export function make(input: {
name: "zig",
extensions: [".zig", ".zon"],
enabled: Effect.sync(() => {
const match = which("zig")
const match = findExecutable("zig")
return match ? [match, "fmt", "$FILE"] : disabled
}),
}
@@ -159,7 +161,7 @@ export function make(input: {
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".clang-format")).length) return disabled
const match = which("clang-format")
const match = findExecutable("clang-format")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
@@ -168,7 +170,7 @@ export function make(input: {
name: "ktlint",
extensions: [".kt", ".kts"],
enabled: Effect.sync(() => {
const match = which("ktlint")
const match = findExecutable("ktlint")
return match ? [match, "-F", "$FILE"] : disabled
}),
}
@@ -177,17 +179,18 @@ export function make(input: {
name: "ruff",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
if (!which("ruff")) return disabled
const bin = findExecutable("ruff")
if (!bin) return disabled
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
const found = yield* findUp(config)
if (!found.length) continue
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
return ["ruff", "format", "$FILE"]
return [bin, "format", "$FILE"]
}
}
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
const found = yield* findUp(dependency)
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"]
}
return disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
@@ -197,7 +200,7 @@ export function make(input: {
name: "air",
extensions: [".R"],
enabled: Effect.gen(function* () {
const bin = which("air")
const bin = findExecutable("air")
if (!bin) return disabled
const output = yield* commandOutput([bin, "--help"])
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
@@ -210,34 +213,34 @@ export function make(input: {
name: "uv",
extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () {
const bin = which("uv")
const bin = findExecutable("uv")
if (!bin) return disabled
const output = yield* commandOutput([bin, "format", "--help"])
return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
}),
}
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
const dart = executable("dart", [".dart"], ["format", "$FILE"])
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable)
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable)
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable)
const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable)
const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
enabled: Effect.gen(function* () {
if (!(yield* findUp(".ocamlformat")).length) return disabled
const match = which("ocamlformat")
const match = findExecutable("ocamlformat")
return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable)
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable)
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable)
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable)
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable)
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable)
const pint: Info = {
name: "pint",
@@ -253,9 +256,9 @@ export function make(input: {
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable)
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable)
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable)
return [
gofmt,
@@ -287,12 +290,17 @@ export function make(input: {
] satisfies Info[]
}
function executable(name: string, extensions: readonly string[], args: string[]): Info {
function executable(
name: string,
extensions: readonly string[],
args: string[],
findExecutable: (name: string) => string | null,
): Info {
return {
name,
extensions,
enabled: Effect.sync(() => {
const match = which(name)
const match = findExecutable(name)
return match ? [match, ...args] : false
}),
}
+64 -56
View File
@@ -1,15 +1,13 @@
export * as InstructionDiscovery from "./instruction-discovery"
import { Array, Context, Effect, Layer, Schema } from "effect"
import { isAbsolute, join, relative, sep } from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { Instructions } from "./instructions/index"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { Instructions } from "./instructions/index"
import { AbsolutePath } from "./schema"
import { State } from "./state"
class File extends Schema.Class<File>("InstructionDiscovery.File")({
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
path: AbsolutePath,
content: Schema.String,
}) {}
@@ -17,7 +15,30 @@ class File extends Schema.Class<File>("InstructionDiscovery.File")({
const Files = Schema.Array(File)
const key = Instructions.Key.make("core/instructions")
export interface Interface {
export const Event = {
Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }),
}
export type Data = {
files: Map<AbsolutePath, Types.DeepMutable<File>>
available: boolean
}
export type Draft = {
list: () => readonly Types.DeepMutable<File>[]
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
// sibling contributors interleave by transform registration order.
add: (file: File) => void
update: (path: string, update: (file: Types.DeepMutable<File>) => void) => void
remove: (path: string) => void
unavailable: () => void
}
export interface Interface extends State.Transformable<Draft> {
// Discovery policy lives here because internal plugins have no per-composition options channel.
// Move it into plugin config once plugins can consume their own options.
readonly project: boolean
readonly list: () => Effect.Effect<File[] | Instructions.Unavailable>
readonly load: () => Effect.Effect<Instructions.List>
}
@@ -32,9 +53,26 @@ export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const state = State.create<Data, Draft>({
name: "instruction-discovery",
initial: () => ({ files: new Map(), available: true }),
draft: (draft) => ({
list: () => Array.from(draft.files.values()),
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
update: (path, update) => {
const current = draft.files.get(AbsolutePath.make(path))
if (!current) return
update(current)
current.path = AbsolutePath.make(path)
},
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
unavailable: () => {
draft.available = false
},
}),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
Instructions.make<ReadonlyArray<File>>({
@@ -49,52 +87,22 @@ export const layer = (options?: Options) =>
},
})
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const fromProject = relative(stop, start)
const insideProject =
fromProject === "" ||
(fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
const discovered = new Set(
yield* Effect.forEach(
options?.project === false || !insideProject
? []
: yield* fs.up({
targets: ["AGENTS.md"],
start,
stop,
}),
fs.resolve,
),
)
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
const files = yield* Effect.forEach(
paths,
(path) =>
fs
.readFileStringSafe(path)
.pipe(
Effect.map((content) =>
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
),
),
{ concurrency: "unbounded" },
)
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
return Instructions.unavailable
return files.filter((file): file is File => file !== undefined)
const list = Effect.fn("InstructionDiscovery.list")(function* () {
const current = state.get()
if (!current.available) return Instructions.unavailable
return Array.from(current.files.values())
})
return Service.of({
load: () =>
observe().pipe(
Effect.map((files) =>
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
),
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
),
project: options?.project !== false,
transform: state.transform,
reload: state.reload,
list,
load: Effect.fn("InstructionDiscovery.load")(function* () {
const files = yield* list()
if (!Array.isArray(files)) return source(files)
return source(files.length === 0 ? Instructions.removed : files)
}),
})
}),
)
@@ -103,7 +111,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Global.node, Location.node],
deps: [Bus.node],
})
}
+3
View File
@@ -15,6 +15,7 @@ import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
import { Form } from "./form"
import { Image } from "./image"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
@@ -98,6 +99,8 @@ const locationServiceNodes = [
Snapshot.node,
SessionRunnerLLM.node,
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
+63 -56
View File
@@ -1,11 +1,8 @@
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app"
import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "./bus"
@@ -13,6 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model"
import { Provider } from "./provider"
import { KV } from "./kv"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -537,6 +535,18 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}
export const layer = (options?: Options) =>
Layer.effect(
Service,
@@ -544,6 +554,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const app = yield* App.Metadata
const kv = yield* KV.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -554,21 +565,28 @@ export const layer = (options?: Options) =>
),
)
const source = options?.url || "https://models.opencode.ai"
const source = options?.url || defaultSource
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const filepath = path.join(
Global.Path.cache,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const key = cacheKey(source)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
const lock = Semaphore.makeUnsafe(1)
const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
}
if (value !== undefined) yield* kv.remove(key)
})
const fresh = Effect.fnUntraced(function* () {
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stat) return false
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
return Date.now() - mtime < Duration.toMillis(ttl)
const cached = yield* loadFromCache()
if (!cached) return false
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
})
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
@@ -580,15 +598,12 @@ export const layer = (options?: Options) =>
)
})
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch((error) => {
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
}
return Effect.succeed(undefined)
}),
)
const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch(() => Effect.succeed(undefined)),
)
: Effect.succeed(undefined)
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
@@ -596,33 +611,27 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
yield* fs.writeWithDirs(tempfile, text).pipe(
Effect.andThen(fs.rename(tempfile, filepath)),
Effect.catch((error) =>
Effect.gen(function* () {
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
return text
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
yield* kv.set(key, { updatedAt: Date.now(), body: text })
return catalog
})
const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return normalize(fromDisk)
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled)
if (!fetch) return []
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite()
}),
)
return normalize(JSON.parse(text) as Record<string, SourceProvider>)
return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
@@ -630,21 +639,19 @@ export const layer = (options?: Options) =>
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
if (!force && (yield* fresh())) return
yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
// Re-check under the lock: another process may have refreshed between
// our outer check and lock acquisition.
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
yield* lock
.withPermit(
Effect.gen(function* () {
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
)
.pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
})
if (fetch && !process.argv.includes("--get-yargs-completions")) {
@@ -660,7 +667,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
})
}
+4 -2
View File
@@ -290,8 +290,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
transform: (callback) =>
skill.transform((draft) => {
callback({
source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)),
list: draft.list,
list: () => mutable(draft.list()),
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: draft.update,
remove: draft.remove,
})
}),
},
+51
View File
@@ -1,6 +1,8 @@
export * as PluginInternal from "./internal"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent"
@@ -10,6 +12,7 @@ import { Config } from "../config"
import { Credential } from "../credential"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigInstructionPlugin } from "../config/plugin/instruction"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
@@ -24,6 +27,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { InstructionDiscovery } from "../instruction-discovery"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
@@ -37,6 +41,8 @@ import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
import { Skill } from "../skill"
import { SkillDiscovery } from "../skill/discovery"
import { Watcher } from "../filesystem/watcher"
import { PatchTool } from "../tool/plugin/patch"
import { EditTool } from "../tool/plugin/edit"
import { GlobTool } from "../tool/plugin/glob"
@@ -79,6 +85,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const instructionDiscovery = yield* InstructionDiscovery.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const location = yield* Location.Service
@@ -95,7 +102,9 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(Agent.Service, agent),
@@ -112,6 +121,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Global.Service, global),
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(InstructionDiscovery.Service, instructionDiscovery),
Context.make(Integration.Service, integration),
Context.make(KV.Service, kv),
Context.make(Location.Service, location),
@@ -128,7 +138,9 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown),
)
})
@@ -137,6 +149,44 @@ type ContextServices<A> = A extends Context.Context<infer R> ? R : never
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
export const requirements = LayerNode.group([
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
InstructionDiscovery.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
WebSearch.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
Watcher.node,
WellKnown.node,
])
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
@@ -164,6 +214,7 @@ const pre = [
] as const satisfies readonly InternalPlugin[]
const post = [
ConfigInstructionPlugin.Plugin,
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
+15 -21
View File
@@ -28,29 +28,23 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
yield* ctx.skill.transform((draft) => {
draft.source(
Skill.EmbeddedSource.make({
type: "embedded",
skill: Skill.Info.make({
id: Skill.ID.make("opencode"),
name: Skill.Name.make("OpenCode"),
description: OpencodeDescription,
location: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
}),
draft.add(
Skill.Info.make({
id: Skill.ID.make("opencode"),
name: Skill.Name.make("OpenCode"),
description: OpencodeDescription,
location: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
}),
)
draft.source(
Skill.EmbeddedSource.make({
type: "embedded",
skill: Skill.Info.make({
id: Skill.ID.make("report"),
name: Skill.Name.make("Report"),
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
content: reportContent,
}),
draft.add(
Skill.Info.make({
id: Skill.ID.make("report"),
name: Skill.Name.make("Report"),
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
content: reportContent,
}),
)
})
+21 -208
View File
@@ -1,48 +1,17 @@
export * as PluginSupervisor from "./supervisor"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Agent } from "../agent"
import { Catalog } from "../catalog"
import { Command } from "../command"
import { Config } from "../config"
import { Credential } from "../credential"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
import { Watcher } from "../filesystem/watcher"
import { Form } from "../form"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission"
import { Plugin } from "../plugin"
import { PluginPromise } from "../plugin/promise"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
import { Skill } from "../skill"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { Tool } from "../tool"
import { WebSearch } from "../websearch"
import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
import { SdkPlugins } from "./sdk"
import { importModule } from "@opencode-ai/util/runtime-import"
@@ -63,65 +32,10 @@ const PluginModule = Schema.Struct({
]),
})
type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return { ...operation, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly Operation[],
operations: readonly ConfigPluginSource.Operation[],
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -168,7 +82,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
]
})
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
const load = Effect.fn("PluginSupervisor.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
@@ -192,31 +108,6 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
} satisfies Plugin.Versioned
})
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
@@ -224,72 +115,29 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
const layer = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
const config = yield* Config.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const ready = { current: yield* Deferred.make<void>() }
let observed = 0
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
entries: readonly Entry[],
operations: readonly Operation[],
) {
for (const operation of operations) {
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
if (watched.has(operation.target)) continue
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
// Directory targets can't hot-reload (their stat mtime ignores edits
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
),
Effect.forkScoped({ startImmediately: true }),
)
}
})
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const entries = yield* config.entries()
const operations = yield* scan(entries)
yield* watchConfiguredSources(entries, operations)
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
).pipe(
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
@@ -315,48 +163,13 @@ const layer = Layer.effect(
}),
)
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
const nodeDeps = [
Plugin.node,
SdkPlugins.node,
ConfigPluginSource.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
] as const
export const node = makeLocationNode({
service: Service,
layer: nodeLayer,
deps: [
Plugin.node,
SdkPlugins.node,
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
Tool.node,
Watcher.node,
WebSearch.node,
WellKnown.node,
],
})
export { layer }
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+9 -2
View File
@@ -9,6 +9,7 @@ import { Bus } from "./bus"
import { Location } from "./location"
import { PtyID } from "./pty/schema"
import { ShellSelect } from "./shell/select"
import { Global } from "@opencode-ai/util/global"
import { lazy } from "./util/lazy"
const BUFFER_LIMIT = 1024 * 1024 * 2
@@ -96,6 +97,7 @@ export const layer = (options?: ShellSelect.Options) =>
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
@@ -165,7 +167,8 @@ export const layer = (options?: ShellSelect.Options) =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
const command =
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@@ -315,7 +318,11 @@ export const layer = (options?: ShellSelect.Options) =>
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] })
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node],
})
}
export const node = configured()
+11 -7
View File
@@ -34,6 +34,8 @@ export namespace RipgrepBinary {
const fs = yield* FSUtil.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner
const global = yield* Global.Service
const findExecutable = (name: string) => which(name, undefined, global.bin)
const run = Effect.fnUntraced(function* (command: string, args: string[]) {
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
@@ -53,10 +55,12 @@ export namespace RipgrepBinary {
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const shell =
(yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ??
"powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
@@ -91,10 +95,10 @@ export namespace RipgrepBinary {
return Service.of({
filepath: yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
@@ -103,10 +107,10 @@ export namespace RipgrepBinary {
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
const archive = path.join(global.bin, filename)
yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
yield* fs.ensureDir(global.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
@@ -127,6 +131,6 @@ export namespace RipgrepBinary {
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node],
})
}
+3 -1
View File
@@ -143,7 +143,9 @@ export const layer = (options?: ShellSelect.Options) =>
})
const resolve = () =>
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
+51 -36
View File
@@ -34,15 +34,19 @@ function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string, options?: Options) {
function findExecutable(name: string, bin?: string) {
return which(name, undefined, bin)
}
function full(file: string, options?: Options, bin?: string) {
if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell
return shell
}
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
return which(shell) || shell
if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell
return findExecutable(shell, bin) || shell
}
function meta(file: string) {
@@ -57,21 +61,26 @@ function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string, options?: Options) {
const shell = full(file, options)
function resolve(file: string, options?: Options, bin?: string) {
const shell = full(file, options, bin)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
return findExecutable(shell, bin) ?? undefined
}
function win(options?: Options) {
function win(options?: Options, bin?: string) {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
[
findExecutable("pwsh", bin),
findExecutable("powershell", bin),
gitbash(options, bin),
process.env.COMSPEC || "cmd.exe",
]
.filter((item): item is string => Boolean(item))
.map((file) => full(file, options)),
.map((file) => full(file, options, bin)),
),
)
}
@@ -82,27 +91,27 @@ async function unix() {
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file, options)
const shell = resolve(file, options, bin)
if (shell) return shell
}
if (process.platform === "win32") return win(options)[0]
return fallback()
if (process.platform === "win32") return win(options, bin)[0]
return fallback(bin)
}
export function gitbash(options?: Options) {
export function gitbash(options?: Options, bin?: string) {
if (process.platform !== "win32") return
if (options?.gitbash) return options.gitbash
const git = which("git")
const git = findExecutable("git", bin)
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (stat(file)?.size) return file
}
function fallback() {
function fallback(bin?: string) {
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
const bash = findExecutable("bash", bin)
if (bash) return bash
return "/bin/sh"
}
@@ -120,12 +129,12 @@ export function ps(file: string) {
return meta(file)?.ps === true
}
function info(file: string, options?: Options): Item {
const item = full(file, options)
function info(file: string, options?: Options, bin?: string): Item {
const item = full(file, options, bin)
const n = name(item)
return {
path: item,
name: resolve(n, options) ? n : item,
name: resolve(n, options, bin) ? n : item,
acceptable: ok(item),
}
}
@@ -139,30 +148,36 @@ export function args(file: string, command: string) {
return ["-c", command]
}
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
let defaultPreferred: { bin?: string; value: string } | undefined
let defaultAcceptable: { bin?: string; value: string } | undefined
export function preferred(configShell?: string, options?: Options) {
if (configShell) return select(configShell, options)
if (options?.gitbash) return select(process.env.SHELL, options)
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
export function preferred(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, undefined, bin)
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
const cached = defaultPreferred
if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
defaultPreferred = { bin, value }
return value
}
preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string, options?: Options) {
if (configShell) return select(configShell, options, { acceptable: true })
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true })
return defaultAcceptable
export function acceptable(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, { acceptable: true }, bin)
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
const cached = defaultAcceptable
if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
defaultAcceptable = { bin, value }
return value
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
export async function list(options?: Options): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options) : await unix()
return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options))
export async function list(options?: Options, bin?: string): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options, bin) : await unix()
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
}
+23 -192
View File
@@ -2,17 +2,12 @@ export * as Skill from "./skill"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
import { Context, Effect, Layer, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
import { Bus } from "./bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Permission } from "./permission"
import { AbsolutePath } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state"
import { Watcher } from "./filesystem/watcher"
export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource
@@ -57,38 +52,18 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
].join("\n")
}
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
const metadataBoolean = (metadata: unknown, key: string) => {
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const value = (metadata as { readonly [key: string]: unknown })[key]
if (typeof value === "boolean") return value
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (normalized === "true") return true
if (normalized === "false") return false
return undefined
}
export type Data = {
sources: Types.DeepMutable<Source>[]
skills: Map<ID, Types.DeepMutable<Info>>
}
export type Draft = {
source: (source: Source) => void
list: () => readonly Source[]
list: () => readonly Types.DeepMutable<Info>[]
add: (skill: Info) => void
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
remove: (id: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]>
}
@@ -97,179 +72,35 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sk
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const changes = yield* PubSub.unbounded<string>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const changed = yield* lock.withPermit(
Effect.gen(function* () {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
)
if (invalidated.length === 0) return false
cache.clear()
yield* FiberMap.clear(watches)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
return true
}),
)
if (!changed) return
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe(
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
)
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{
onlyIfMissing: true,
startImmediately: true,
},
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
directory: string,
) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) {
yield* watch(target, "file")
}
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const state = State.create<Data, Draft>({
name: "skill",
initial: () => ({ sources: [] }),
initial: () => ({ skills: new Map() }),
draft: (draft) => ({
source: (source) => {
if (draft.sources.some((item) => Source.equals(item, source))) return
draft.sources.push(source as Types.DeepMutable<Source>)
list: () => Array.from(draft.skills.values()),
add: (skill) => {
draft.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
},
update: (id, update) => {
const current = draft.skills.get(ID.make(id))
if (!current) return
update(current)
current.id = ID.make(id)
},
remove: (id) => {
draft.skills.delete(ID.make(id))
},
list: () => draft.sources as Source[],
}),
finalize: () =>
lock
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
})
const load = Effect.fn("Skill.load")(function* (source: Source) {
const skills: Info[] = []
if (source.type === "embedded") {
yield* Effect.logDebug("skill source loaded", {
source: Source.key(source),
type: source.type,
directories: [],
skills: [source.skill.id],
})
return { skills: [source.skill], paths: [] }
}
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const paths = [...roots]
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
const external = path.dirname(resolved)
paths.push(external)
yield* watch(external, "directory")
}
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) continue
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
if (!frontmatter) continue
const id =
path.dirname(filepath) === directory
? path.basename(filepath, ".md")
: path.basename(path.dirname(filepath))
skills.push({
id: ID.make(id),
name: Name.make(frontmatter.name ?? id),
description: frontmatter.description,
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
location: AbsolutePath.make(filepath),
content: markdown.content,
})
}
}
yield* Effect.logDebug("skill source loaded", {
source: Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
})
return { skills, paths }
})
const list = Effect.fn("Skill.list")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
}),
)
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
transform: state.transform,
reload: state.reload,
sources: Effect.fn("Skill.sources")(function* () {
return state.get().sources
list: Effect.fn("Skill.list")(function* () {
return Array.from(state.get().skills.values())
}),
list,
})
}),
)
@@ -277,5 +108,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
deps: [Bus.node],
})
+2 -3
View File
@@ -1,10 +1,9 @@
import whichPkg from "which"
import path from "path"
import { Global } from "@opencode-ai/util/global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const full = base && bin ? base + path.delimiter + bin : base || bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: full,
+20 -28
View File
@@ -3,6 +3,7 @@ export * as Vcs from "./vcs"
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -10,8 +11,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus"
import { Git } from "./git"
import { Watcher } from "./filesystem/watcher"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
@@ -45,36 +44,29 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const git = yield* Git.Service
const watcher = yield* Watcher.Service
const impl = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
if (vcs && impl) {
yield* Effect.gen(function* () {
const discovered = vcs.type === "git" ? (yield* git.repo.discover(location.directory))?.gitDirectory : undefined
const target = discovered ?? vcs.store
const dir = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
const keep = vcs.type === "git" ? ["HEAD", "HEAD.lock"] : ["branch"]
const ignore = (yield* fs.readDirectoryEntries(dir).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (keep.includes(entry.name) ? [] : [entry.name]),
)
const updates = yield* watcher.subscribe({ path: dir, type: "directory", ignore })
yield* updates.pipe(
Stream.filter((update) => keep.includes(path.basename(update.path))),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: update.path } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to watch vcs metadata", { cause })))
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
const isBranchMetadata =
vcs.type === "git"
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
: (file: string) => path.resolve(file) === path.join(store, "branch")
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}
return Service.of({
@@ -96,5 +88,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node, Git.node, Watcher.node],
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
})
@@ -0,0 +1,6 @@
---
name: first
description: First skill
---
# first
@@ -0,0 +1,6 @@
---
name: second
description: Second skill
---
# second
+35 -1
View File
@@ -5,8 +5,10 @@ import { describe, expect } from "bun:test"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
@@ -19,10 +21,19 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
const staticIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[ConfigPluginSource.node, ConfigPluginSource.empty],
[Global.node, tempGlobalLayer],
]),
)
describe("PluginSupervisor config", () => {
@@ -157,6 +168,29 @@ describe("PluginSupervisor config", () => {
),
)
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin")
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
true,
)
}),
)
it.live("reloads an auto-discovered plugin when its file changes", () =>
withLocation(
undefined,
+4 -10
View File
@@ -46,9 +46,7 @@ describe("config plugin reloads", () => {
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command")
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(true)
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
@@ -69,12 +67,8 @@ describe("config plugin reloads", () => {
}),
)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(false)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(false)
expect((yield* skills.list()).some((skill) => skill.id === "second")).toBe(true)
}).pipe(
Effect.provide(Config.testLayer([config("first")])),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
@@ -89,7 +83,7 @@ function config(name: string) {
info: decode({
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
skills: [`/skills/${name}`],
skills: [path.join(import.meta.dir, "fixture", "skills", `${name}-source`)],
references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
}),
+360 -68
View File
@@ -1,87 +1,379 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config"
import {
AgentsDirectory,
ClaudeDirectory,
Directory as ConfigDirectory,
Document,
type Entry,
Info,
} from "@opencode-ai/schema/config"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { tmpdir } from "../fixture/tmpdir"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const it = testEffect(Layer.empty)
const urls = new Map<string, AbsolutePath[]>()
const failedUrls = new Set<string>()
let pulls = 0
const discoveryLayer = Layer.succeed(
SkillDiscovery.Service,
SkillDiscovery.Service.of({
pull: (url) => {
pulls++
if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
return Effect.succeed(urls.get(url) ?? [])
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
discoveryLayer,
watcherLayer,
),
)
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigSkillPlugin.Plugin", () => {
it.effect("registers configured skill directories and URLs", () =>
Effect.gen(function* () {
const directory = AbsolutePath.make("/repo/packages/app")
const sources: Skill.Source[] = []
const transform = Effect.fnUntraced(function* (update: (draft: Skill.Draft) => void | Effect.Effect<void>) {
const result = update({
source: (source) => {
sources.push(source)
},
list: () => sources,
})
if (Effect.isEffect(result)) yield* result
const dispose = Effect.sync(() => {
sources.length = 0
})
yield* Effect.addFinalizer(() => dispose)
return { dispose }
})
function write(directory: string, name: string, description: string) {
return fs.writeFile(
path.join(directory, name, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}`,
)
}
yield* ConfigSkillPlugin.Plugin.effect(
host({
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
}),
).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
Effect.provide(
Config.testLayer([
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Document({
type: "document",
info: decode({
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
}),
}),
]),
const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
const service = yield* Skill.Service
yield* ConfigSkillPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: service.transform,
reload: service.reload,
},
}),
).pipe(
Effect.provide(Config.testLayer(entries)),
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
)
return service
})
const start = (skills: string[], directory: string) =>
startEntries(
[
new Document({
type: "document",
info: decode({ skills }),
}),
],
directory,
)
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* watcher.emit(update)
yield* Deferred.await(deferred).pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(fiber)
})
}
describe("SkillFile.parse", () => {
it.effect("parses root and nested skill ids and metadata flags", () =>
Effect.sync(() => {
const directory = "/repo/skills"
expect(
SkillFile.parse(
directory,
"/repo/skills/manual/SKILL.md",
`---
name: Manual
description: Manual only
metadata:
opencode/slash: "true"
opencode/autoinvoke: false
---
# manual`,
),
)
expect(sources).toEqual([
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
}),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
}),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
}),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
}),
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join("/home/test", "shared-skills")),
}),
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
])
).toEqual({
_tag: "Parsed",
skill: {
id: Skill.ID.make("manual"),
name: Skill.Name.make("Manual"),
description: "Manual only",
slash: true,
autoinvoke: false,
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
content: "# manual",
},
})
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
_tag: "Parsed",
skill: { id: Skill.ID.make("foo") },
})
expect(
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
).toEqual({ _tag: "Skipped", reason: "markdown" })
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
_tag: "Skipped",
reason: "frontmatter",
issue: expect.anything(),
})
}),
)
})
describe("ConfigSkillPlugin.Plugin", () => {
it.live("maps config entry types to skill directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const claude = path.join(tmp.path, "claude")
const agents = path.join(tmp.path, "agents")
const opencode = path.join(tmp.path, "opencode")
const home = path.join(tmp.path, "home")
const directory = path.join(tmp.path, "project")
const expected = [
path.join(claude, "skills"),
path.join(agents, "skills"),
path.join(opencode, "skill"),
path.join(opencode, "skills"),
path.join(home, "shared"),
path.join(directory, "relative"),
]
yield* Effect.promise(() => Promise.all(expected.map((item) => fs.mkdir(item, { recursive: true }))))
yield* startEntries(
[
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
],
directory,
home,
)
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
}),
),
),
)
it.live("loads directory and URL sources with later-source precedence", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "review"), { recursive: true })
await fs.mkdir(path.join(second, "review"), { recursive: true })
await write(first, "review", "First")
await write(second, "review", "Second")
})
pulls = 0
urls.set("https://example.test/skills/", [AbsolutePath.make(second)])
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
expect(pulls).toBe(1)
}),
),
),
)
it.live("keeps directory skills when a URL source fails", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Available")
})
const url = "https://unreachable.example.test/skills/"
failedUrls.add(url)
const skill = yield* start([tmp.path, url], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
failedUrls.delete(url)
}),
),
),
)
it.live("rescans directory sources when watched files change", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial")
})
const skill = yield* start([tmp.path], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial")
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review")
})
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "review", "SKILL.md") })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: path.join(tmp.path, "review", "SKILL.md") })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"), process.platform === "win32" ? "junction" : undefined)
})
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("reloads symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source, process.platform === "win32" ? "junction" : undefined)
})
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source, process.platform === "win32" ? "junction" : undefined)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
}),
),
),
)
it.live("follows missing source directories as their parents appear", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
yield* Effect.promise(async () => {
await fs.mkdir(path.join(source, "deploy"), { recursive: true })
await write(source, "deploy", "Deploy")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
})
+31 -5
View File
@@ -11,11 +11,19 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
const run = <A, E>(
effect: Effect.Effect<A, E, SqlClient | Global.Service>,
global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
effect.pipe(
Effect.provideService(Global.Service, global),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
Effect.scoped,
),
)
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
@@ -31,7 +39,7 @@ describe("DatabaseMigration", () => {
Effect.scoped(Layer.build(layer)),
),
{ concurrency: "unbounded" },
),
).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
)
})
@@ -127,7 +135,8 @@ describe("DatabaseMigration", () => {
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
`)
yield* db.transaction((tx) => importLegacyCredentials(tx, source))
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
[
@@ -159,11 +168,28 @@ describe("DatabaseMigration", () => {
value: JSON.stringify(["https://example.com"]),
})
}),
Global.make({ data: tmp.path }),
)
expect(await Bun.file(source).text()).toBe(content)
})
test("skips legacy credential import when the source file is absent", async () => {
await using tmp = await tmpdir()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
}),
Global.make({ data: tmp.path }),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
+233 -8
View File
@@ -1,15 +1,28 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Config.testLayer()
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
@@ -27,6 +40,7 @@ describe("Watcher.testLayer", () => {
yield* test.emit({ type: "update", path: "/root/file.md" })
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
// subscriptions() reports acquired watches, so paths come back resolved.
expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
}).pipe(Effect.provide(Watcher.testLayer)),
)
@@ -112,20 +126,167 @@ describe("Watcher lifecycle", () => {
expect(counts.unsubscribes).toBe(0)
return consumer
}).pipe(withNative(native))
// Closing the layer scope tears the native subscription down while the
// consumer still holds a reference; the consumer's own release as its
// stream ends must not tear it down a second time.
yield* Fiber.join(consumer)
expect(counts.unsubscribes).toBe(1)
})
})
})
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
return Effect.provide(built)
}
describeNative("Watcher", () => {
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: {
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
},
) {
return Effect.acquireRelease(
Effect.promise(async () => {
const tmp = await tmpdir()
if (options?.vcs === "hg") {
await fs.mkdir(path.join(tmp.path, ".hg"))
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
await $`git config user.name Test`.cwd(tmp.path).quiet()
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
await options.init?.(tmp.path)
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
}
describe("LocationWatcher subscriptions", () => {
it.live("watches only exact Git branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
}),
{ vcs: "git", watcher },
)
})
it.live("watches only exact Hg branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
}),
{ vcs: "hg", watcher },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
}),
Effect.forkScoped,
)
yield* Effect.yieldNow
return { deferred, fiber }
})
}
function maybeNextUpdate<E>(
check: (event: WatcherEvent) => boolean,
trigger: Effect.Effect<void, E>,
timeout: Duration.Input = "5 seconds",
) {
return Effect.acquireUseRelease(
wait(check),
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
({ fiber }) => Fiber.interrupt(fiber),
)
}
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
return Effect.gen(function* () {
const result = yield* maybeNextUpdate(check, trigger)
if (Option.isSome(result)) return result.value
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
})
}
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
return Effect.gen(function* () {
while (true) {
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
if (Option.isSome(result)) return result.value
}
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
}),
)
}
function ready(file: string, eventFile = file) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
yield* eventuallyUpdate(
(event) => event.file === eventFile,
() => fs.writeFileString(file, content),
).pipe(Effect.asVoid)
})
}
describeNative("LocationWatcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -172,4 +333,68 @@ describeNative("Watcher", () => {
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
it.live("publishes .git/HEAD events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const head = path.join(directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* ready(head)
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({ file: head, event: "change" })
}),
{ vcs: "git" },
),
)
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
describeSymlink("symlinked .git", () => {
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
const head = path.join(directory, ".git", "HEAD")
yield* ready(head, path.join(actual, "HEAD"))
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate(
(event) => event.file === path.join(actual, "HEAD"),
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
),
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}),
{
vcs: "git",
init: async (directory) => {
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
await fs.rename(path.join(directory, ".git"), actual)
await fs.symlink(actual, path.join(directory, ".git"))
},
},
),
)
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg" },
),
)
})
+27
View File
@@ -0,0 +1,27 @@
import path from "path"
import { Global } from "@opencode-ai/util/global"
import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export const tempGlobalLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.map((tmp) => {
const data = path.join(tmp.path, "data")
const cache = path.join(tmp.path, "cache")
return Global.layerWith({
home: path.join(tmp.path, "home"),
data,
cache,
config: path.join(tmp.path, "config"),
state: path.join(tmp.path, "state"),
tmp: path.join(tmp.path, "tmp"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
repos: path.join(data, "repos"),
})
}),
),
)
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Global } from "@opencode-ai/util/global"
describe("global paths", () => {
test("tmp path is the canonical system temp directory", async () => {
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
test("tmp path is created on module load", async () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})
})
+260 -175
View File
@@ -1,49 +1,124 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import fs from "fs/promises"
import path from "path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "@opencode-ai/core/bus"
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "./fixture/global"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { readInitial, readUpdate, state } from "./lib/instructions"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config: string
config?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
}) =>
AppNodeBuilder.build(InstructionDiscovery.node, [
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, Global.layerWith({ config: input.config })],
[Location.node, input.locationServiceLayer],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
])
}) => {
const watcher = Watcher.testLayer
return Layer.mergeAll(
AppNodeBuilder.build(
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
],
),
watcher,
)
}
const start = Effect.fnUntraced(function* () {
yield* ConfigInstructionPlugin.Plugin.effect(host())
return yield* InstructionDiscovery.Service
})
const file = (path: string, content: string) =>
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
const updated = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* watcher.emit(update)
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(fiber)
})
}
describe("InstructionDiscovery", () => {
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
it.effect("stores ordered values with last-write-wins precedence", () =>
Effect.gen(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* discovery.transform((draft) => {
draft.add(file("/repo/AGENTS.md", "first"))
draft.add(file("/repo/packages/AGENTS.md", "package"))
draft.add(file("/repo/AGENTS.md", "last"))
draft.update("/repo/packages/AGENTS.md", (current) => {
current.content = "updated"
current.path = AbsolutePath.make("/ignored")
})
draft.remove("/missing")
})
expect(yield* discovery.list()).toEqual([
file("/repo/AGENTS.md", "last"),
file("/repo/packages/AGENTS.md", "updated"),
])
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
)
it.effect("preserves admitted values while the source is unavailable", () =>
Effect.gen(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* discovery.transform((draft) => draft.unavailable())
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
)
})
describe("ConfigInstructionPlugin.Plugin", () => {
it.live("loads global and upward project files and rescans them on change", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const directory = path.join(project, "packages", "core")
const outside = path.join(tmp.path, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(directory, { recursive: true })
@@ -53,25 +128,15 @@ describe("InstructionDiscovery", () => {
await fs.writeFile(packageFile, "package")
})
const load = InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: global,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
const initialized = yield* readInitial(yield* load)
const discovery = yield* start()
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual([
{ path: globalFile, type: "file" },
{ path: packageFile, type: "file" },
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
{ path: projectFile, type: "file" },
])
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
@@ -82,13 +147,14 @@ describe("InstructionDiscovery", () => {
expect(initialized.text).not.toContain("outside")
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
expect((yield* readUpdate(yield* load, initialized)).text).toContain(
yield* emitAndWait({ type: "update", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
`Instructions from: ${packageFile}\nchanged`,
)
yield* Effect.promise(() => fs.rm(packageFile))
const partial = yield* readUpdate(yield* load, initialized)
expect(partial.text).toBe(
yield* emitAndWait({ type: "delete", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
[
"These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`,
@@ -96,12 +162,30 @@ describe("InstructionDiscovery", () => {
].join("\n\n"),
)
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
expect((yield* readUpdate(yield* load, initialized)).text).toBe(
yield* Effect.promise(() => fs.rm(globalFile))
yield* emitAndWait({ type: "delete", path: globalFile })
yield* Effect.promise(() => fs.rm(projectFile))
yield* emitAndWait({ type: "delete", path: projectFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
"Previously loaded instructions no longer apply.",
)
}),
),
}).pipe(
Effect.provide(
instructionLayer({
config: global,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
@@ -114,115 +198,150 @@ describe("InstructionDiscovery", () => {
Effect.gen(function* () {
const file = path.join(tmp.path, "AGENTS.md")
yield* Effect.promise(() => fs.writeFile(file, ""))
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
)
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
}),
const discovery = yield* start()
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`)
}).pipe(
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
),
),
),
)
it.effect("preserves admitted instructions while observation is unavailable", () =>
Effect.gen(function* () {
const failingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
it.live("discovers a newly created instruction file in an intermediate directory", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const project = path.join(tmp.path, "project")
const intermediate = path.join(project, "packages", "AGENTS.md")
const directory = path.join(project, "packages", "core")
const projectFile = path.join(project, "AGENTS.md")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(projectFile, "project"))
const discovery = yield* start()
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`)
yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
yield* emitAndWait({ type: "create", path: intermediate })
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
"\n\n",
),
}),
),
)
expect(
(yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] })))
.changed,
).toBe(false)
}),
)
it.effect("preserves admitted instructions when a discovered file disappears before read", () =>
Effect.gen(function* () {
const file = AbsolutePath.make("/repo/AGENTS.md")
const racingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([file]),
readFileStringSafe: () => Effect.succeed(undefined),
)
}).pipe(
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
expect(
(yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
).toBe(false)
}),
)
}),
),
)
it.effect("canonicalizes upward discovery boundaries", () =>
it.effect("isolates source failure without failing activation", () => {
const failingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
return Effect.gen(function* () {
const discovery = yield* start()
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(
Effect.provide(
instructionLayer({
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
})
it.effect("marks a discovered file that disappears before read as unavailable", () => {
const discovered = AbsolutePath.make("/repo/AGENTS.md")
const racingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([discovered]),
readFileStringSafe: () => Effect.succeed(undefined),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
return Effect.gen(function* () {
const discovery = yield* start()
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: discovered, content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(
Effect.provide(
instructionLayer({
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
})
it.effect("canonicalizes boundaries and honors project opt-out", () =>
Effect.gen(function* () {
let observed: { targets: string[]; start: string; stop?: string } | undefined
const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
const observingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: (options) =>
Effect.sync(() => {
observed = options
return []
}),
up: (options) => Effect.sync(() => (observed.values.push(options), [])),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
yield* start().pipe(
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: observingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
@@ -233,31 +352,11 @@ describe("InstructionDiscovery", () => {
}),
),
)
expect(observed).toEqual({
targets: ["AGENTS.md"],
start: FSUtil.resolve("/repo"),
stop: FSUtil.resolve("/repo"),
})
}),
)
it.effect("honors the project instruction opt-out", () =>
Effect.gen(function* () {
let scanned = false
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
yield* start().pipe(
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: observingFS,
project: false,
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
@@ -265,25 +364,10 @@ describe("InstructionDiscovery", () => {
}),
),
)
expect(scanned).toBe(false)
}),
)
it.effect("does not discover project instructions outside the canonical project root", () =>
Effect.gen(function* () {
let scanned = false
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
yield* start().pipe(
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
filesystemLayer: observingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
@@ -297,7 +381,8 @@ describe("InstructionDiscovery", () => {
),
)
expect(scanned).toBe(false)
const repo = path.resolve("/repo")
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
}),
)
})
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import os from "os"
import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -16,6 +17,7 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const sessionID = SessionSchema.ID.make("ses_builtin_test")
const temporary = os.tmpdir()
const localDate = (time: number) => new Date(time).toDateString()
const locationLayer = Layer.succeed(
Location.Service,
@@ -29,7 +31,7 @@ const locationLayer = Layer.succeed(
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
]),
)
@@ -49,7 +51,7 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
+10 -2
View File
@@ -9,6 +9,7 @@ import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -21,6 +22,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolDefinitions, waitForTool } from "./lib/tool"
import { Database } from "../src/database/database"
@@ -28,9 +30,15 @@ import { Bus } from "../src/bus"
import { Reference } from "../src/reference"
import { Tool } from "../src/tool"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node])))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
describe("LocationServiceMap", () => {
+65 -55
View File
@@ -1,19 +1,17 @@
import { describe, expect, beforeEach, afterAll, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@opencode-ai/core/provider"
import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
const cacheFile = path.join(Global.Path.cache, "models.json")
const cacheKey = "models-dev:catalog"
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -164,7 +162,18 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}),
)
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
interface MockCache {
readonly values: Map<string, KV.Value>
}
const makeMockKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
@@ -172,31 +181,20 @@ const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fe
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
]),
)
const writeCacheText = (text: string, mtimeMs?: number) =>
Effect.promise(async () => {
await mkdir(Global.Path.cache, { recursive: true })
await writeFile(cacheFile, text)
if (mtimeMs !== undefined) {
const t = mtimeMs / 1000
await utimes(cacheFile, t, t)
}
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs)
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, body: text })
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state)))
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
writeCacheText(cache, JSON.stringify(data), updatedAt)
beforeEach(async () => {
await rm(cacheFile, { force: true })
})
afterAll(async () => {
await rm(cacheFile, { force: true })
})
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state, cache)))
const initialState: MockState = {
body: JSON.stringify(fixture),
@@ -205,12 +203,14 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from disk when cache file exists", () =>
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual(fixtureSnapshot)
@@ -219,11 +219,13 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual([])
@@ -232,14 +234,15 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
Effect.gen(function* () {
yield* writeCacheText("{")
const cache = makeCache()
writeCacheText(cache, "{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
@@ -247,9 +250,10 @@ describe("ModelsDev Service", () => {
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* ModelsDev.Service.use((service) => service.get()).pipe(
Effect.provide(buildLayer(state, { url: "", fetch: true })),
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}),
@@ -257,32 +261,31 @@ describe("ModelsDev Service", () => {
it.live("get() is single-flight under concurrent calls", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
const state = yield* Ref.make(initialState)
const results = yield* provided(
state,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}),
)
const results = yield* Effect.gen(function* () {
const svc = yield* ModelsDev.Service
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
for (const result of results) expect(result).toEqual(fixtureSnapshot)
expect((yield* Ref.get(state)).calls.length).toBe(1)
}),
)
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState)
const first = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const a = yield* svc.get()
// mutate disk between calls — cache should mask the change
yield* writeCache(fixture2)
writeCache(cache, fixture2)
const b = yield* svc.get()
return { a, b }
}),
@@ -294,10 +297,12 @@ describe("ModelsDev Service", () => {
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const before = yield* svc.get()
@@ -308,6 +313,7 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -315,13 +321,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
Effect.gen(function* () {
// Fresh: mtime within the 5-minute TTL.
yield* writeCache(fixture, Date.now() - 1000)
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* provided(
state,
cache,
ModelsDev.Service.use((s) => s.refresh(false)),
)
const final = yield* Ref.get(state)
@@ -329,13 +336,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) fetches when on-disk file is stale", () =>
it.live("refresh(false) fetches when the KV entry is stale", () =>
Effect.gen(function* () {
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const after = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(false)
@@ -350,10 +358,12 @@ describe("ModelsDev Service", () => {
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
const result = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
+5 -1
View File
@@ -19,9 +19,11 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Tool } from "@opencode-ai/core/tool"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer, Stream } from "effect"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
const npmLayer = Layer.succeed(
@@ -53,8 +55,10 @@ export const PluginTestLayer = AppNodeBuilder.build(
PluginHooks.node,
Reference.node,
Skill.node,
SkillDiscovery.node,
PluginHooks.node,
Tool.node,
Watcher.node,
WebSearch.node,
]),
[
+1 -2
View File
@@ -543,11 +543,10 @@ describe("Session.create", () => {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
[Database.node, targetDatabase],
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Bus.node, Bus.configured({ persist: true })],
],
)
+4 -1
View File
@@ -99,7 +99,10 @@ const builtins = Layer.mock(InstructionBuiltIns.Service, {
}),
),
})
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const discovery = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
@@ -83,7 +83,10 @@ const models = Layer.mock(SessionRunnerModel.Service)({
),
})
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
load: () => Effect.succeed(Instructions.empty),
+4 -1
View File
@@ -312,7 +312,10 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, {
}),
),
})
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skillInstructions = Layer.mock(SkillInstructions.Service, {
load: (agent) =>
Effect.succeed(
+71 -407
View File
@@ -1,438 +1,102 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Deferred, Effect, Fiber, Stream } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const urls = new Map<string, AbsolutePath[]>()
let pulls = 0
const discovery = Layer.succeed(
SkillDiscovery.Service,
SkillDiscovery.Service.of({
pull: (url) => {
pulls++
return Effect.succeed(urls.get(url) ?? [])
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
[SkillDiscovery.node, discovery],
[Watcher.node, watcherLayer],
]),
watcherLayer,
),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node])))
function write(directory: string, name: string, description: string) {
return fs.writeFile(
path.join(directory, name, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}`,
)
}
function waitForSkillUpdate() {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
return { deferred, fiber }
const info = (id: string, description: string) =>
Skill.Info.make({
id: Skill.ID.make(id),
name: Skill.Name.make(id),
description,
location: AbsolutePath.make(`/skills/${id}/SKILL.md`),
content: `# ${id}`,
})
}
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
})
}
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
})
}
describe("Skill", () => {
it.live("publishes updates when skill sources change", () =>
it.effect("registers values with last-write-wins precedence", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
yield* skill.transform((draft) => {
draft.add(info("review", "First"))
draft.add(info("deploy", "Deploy"))
draft.add(info("review", "Second"))
expect(draft.list().map((item) => item.id)).toEqual([Skill.ID.make("review"), Skill.ID.make("deploy")])
})
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) =>
skill
.transform((editor) =>
editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
)
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
}),
)
it.live("registers sources and resolves later source precedence", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "review"), { recursive: true })
await fs.mkdir(path.join(second, "review"), { recursive: true })
await write(first, "review", "First")
await write(second, "review", "Second")
await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
})
it.effect("updates and removes registered values", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
yield* skill.transform((draft) => {
draft.add(info("review", "Initial"))
draft.update("review", (value) => {
value.description = "Updated"
value.id = Skill.ID.make("ignored")
})
draft.update("missing", () => Effect.die("unreachable"))
draft.add(info("deploy", "Deploy"))
draft.remove("deploy")
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => {
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(second) })
expect(editor.list()).toEqual([
{ type: "directory", path: AbsolutePath.make(first) },
{ type: "directory", path: AbsolutePath.make(second) },
])
})
expect(yield* skill.sources()).toEqual([
{ type: "directory", path: AbsolutePath.make(first) },
{ type: "directory", path: AbsolutePath.make(second) },
])
expect(yield* skill.list()).toEqual([
Skill.Info.make({
id: Skill.ID.make("foo"),
name: Skill.Name.make("foo"),
slash: true,
location: AbsolutePath.make(path.join(first, "foo.md")),
content: "# foo",
}),
{
id: Skill.ID.make("review"),
name: Skill.Name.make("review"),
description: "Second",
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
content: "# review",
},
])
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
yield* Effect.promise(() => write(second, "review", "Updated Second"))
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
}),
),
),
expect(yield* skill.list()).toEqual([info("review", "Updated")])
}),
)
it.live("loads URL sources and filters skills for agents", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Deploy production")
})
pulls = 0
urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
}),
)
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
expect(pulls).toBe(1)
expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([])
it.effect("restores earlier values when an updating transform is disposed", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
const original = info("review", "Initial")
yield* skill.transform((draft) => draft.add(original))
const updated = yield* skill.transform((draft) =>
draft.update("review", (value) => {
value.description = "Updated"
}),
),
),
)
expect((yield* skill.list())[0]?.description).toBe("Updated")
yield* updated.dispose
expect((yield* skill.list())[0]?.description).toBe("Initial")
expect(original.description).toBe("Initial")
}),
)
it.live("parses opencode metadata flags from skill frontmatter", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true })
await fs.writeFile(
path.join(tmp.path, "manual", "SKILL.md"),
`---
name: manual
description: Manual only
metadata:
opencode/slash: true
opencode/autoinvoke: false
---
# manual`,
)
})
it.live("publishes updates after committed values are visible", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
const bus = yield* Bus.Service
const updated = yield* Deferred.make<Skill.Info[]>()
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => skill.list().pipe(Effect.flatMap((values) => Deferred.succeed(updated, values)))),
Effect.forkScoped,
)
yield* Effect.yieldNow
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect(yield* skill.list()).toEqual([
{
id: Skill.ID.make("manual"),
name: Skill.Name.make("manual"),
description: "Manual only",
slash: true,
autoinvoke: false,
location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
content: "# manual",
},
])
}),
),
),
yield* skill.transform((draft) => draft.add(info("review", "Visible")))
expect(yield* Deferred.await(updated).pipe(Effect.timeout("1 second"))).toEqual([info("review", "Visible")])
yield* Fiber.interrupt(fiber)
}),
)
it.live("clears cached skills when sources reload", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
let refreshed: Skill.Info[] = []
const unsubscribe = yield* bus.listen((event) => {
if (event.type !== Skill.Event.Updated.type) return Effect.void
return skill.list().pipe(
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
Effect.asVoid,
)
})
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* skill.reload().pipe(Effect.timeout("1 second"))
yield* unsubscribe
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect(yield* watcher.subscriptions()).toEqual([
{ path: tmp.path, type: "directory" },
{ path: tmp.path, type: "directory" },
])
it.effect("filters values by agent permissions", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
}),
),
),
)
it.live("reloads project sources created after their missing parent", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const file = path.join(source, "deploy", "SKILL.md")
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file), { recursive: true })
await write(source, "deploy", "Deploy production")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
{ path: source, type: "directory" },
])
}),
),
),
)
it.live("watches directory sources for added and changed skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review changes")
})
const review = path.join(tmp.path, "review", "SKILL.md")
yield* emitAndWait({ type: "create", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"))
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("invalidates symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source)
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
}),
),
),
)
const agent = yield* agents.get(Agent.ID.make("reviewer"))
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
}),
)
})
+2
View File
@@ -33,6 +33,7 @@ import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
@@ -138,6 +139,7 @@ const layer = AppNodeBuilder.build(
[
[SessionExecution.node, executionNode],
[Permission.node, permission],
[Global.node, tempGlobalLayer],
],
)
-1
View File
@@ -81,7 +81,6 @@ describe("SkillTool", () => {
Skill.Service.of({
transform: (_transform) => Effect.die("unused"),
reload: () => Effect.die("unused"),
sources: () => Effect.die("unused"),
list: () => Effect.succeed(current),
}),
)
+6 -1
View File
@@ -4,6 +4,7 @@ import path from "path"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
@@ -26,6 +27,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
@@ -100,7 +102,10 @@ const layer = AppNodeBuilder.build(
PluginRuntime.providerNode,
LocationServiceMap.node,
]),
[[SessionExecution.node, executionNode]],
[
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
],
)
const it = testEffect(layer)
+10 -4
View File
@@ -18,9 +18,14 @@ import { tmpdir } from "./fixture/tmpdir"
import path from "path"
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) =>
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope | Global.Service>) =>
Effect.runPromise(
Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))),
Effect.scoped(
effect.pipe(
Effect.provideService(Global.Service, Global.make({ data: path.join(process.cwd(), ".test-data") })),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
),
),
)
const session = (
@@ -772,7 +777,7 @@ describe("V1Migration database workflow", () => {
`)
})
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) =>
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Global.Service | Scope.Scope>) =>
run(
Effect.gen(function* () {
const db = yield* makeDb
@@ -935,6 +940,7 @@ describe("V1Migration database workflow", () => {
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
yield* db.run(
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`,
)
@@ -944,7 +950,7 @@ describe("V1Migration database workflow", () => {
project_id: "global",
})
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
worktree: path.parse(Global.Path.data).root,
worktree: path.parse(global.data).root,
})
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
value: '{"phase":"completed"}',
+9 -4
View File
@@ -8,6 +8,7 @@ import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -41,9 +42,7 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
withTmp((directory) =>
Effect.promise(() => hg(directory, "init")).pipe(
Effect.andThen(f(directory).pipe(provide(directory))),
),
Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
)
async function hg(directory: string, ...args: string[]) {
@@ -125,7 +124,13 @@ describeHg("Vcs mercurial", () => {
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
event: "change",
})
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
+37 -125
View File
@@ -8,66 +8,27 @@ import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const describeNative = process.env.CI ? describe.skip : describe
const locationLayer = (directory: string, git?: boolean) =>
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
)
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [[Location.node, locationLayer(directory, input.git)]]),
)
function fakeWatcher() {
const subscriptions: Watcher.WatchInput[] = []
const active = new Set<(update: Watcher.Update) => void>()
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
active.add(input.publish)
return {
unsubscribe: () => {
active.delete(input.publish)
return Promise.resolve()
},
}
}),
})
return {
subscriptions: () => [...subscriptions],
emit: (update: Watcher.Update) => {
for (const publish of active) publish(update)
},
layer: Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))),
}
}
const provideFake = (directory: string, fake: ReturnType<typeof fakeWatcher>, git = true) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[Location.node, locationLayer(directory, git)],
[Watcher.node, fake.layer],
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
),
],
]),
)
@@ -132,84 +93,35 @@ describe("Vcs", () => {
),
)
it.live("watches git branch metadata", () =>
withTmp((directory) => {
const fake = fakeWatcher()
return Effect.promise(() => initRepo(directory)).pipe(
Effect.andThen(
Effect.gen(function* () {
yield* Vcs.Service
expect(fake.subscriptions()).toHaveLength(1)
const git = fake.subscriptions()[0]
if (git?.type !== "directory") throw new Error("expected a directory watch")
expect(git.path).toBe(path.join(directory, ".git"))
expect(git.ignore ?? []).not.toContain("HEAD")
expect(git.ignore ?? []).toContain("objects")
}).pipe(provideFake(directory, fake)),
),
)
}),
)
it.live("caches branch info and publishes HEAD changes", () =>
withTmp((directory) => {
const fake = fakeWatcher()
return Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
fake.emit({ type: "update", path: path.join(directory, ".git", "index.lock") })
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
fake.emit({ type: "update", path: path.join(directory, ".git", "HEAD.lock") })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
}).pipe(provideFake(directory, fake)),
),
)
}),
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
}),
),
)
describeNative("native watches", () => {
it.live("publishes branch updates on git checkout", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
_tag: "Some",
value: { data: { branch: "feature" } },
})
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
}),
),
{ timeout: 15_000 },
)
})
it.live("diffs the working copy against HEAD with patches", () =>
withGit((directory) =>
Effect.gen(function* () {
+8 -4
View File
@@ -44,6 +44,10 @@ export interface MermaidMarkdownRendererOptions {
muted?: ColorInput
warning?: ColorInput
background?: ColorInput
request?: ColorInput
response?: ColorInput
note?: ColorInput
noteBackground?: ColorInput
}
}
@@ -131,12 +135,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
participant: color(colors.primary),
lifeline: color(colors.muted),
group: color(colors.secondary),
request: color(colors.primary),
response: color(colors.primary),
request: color(colors.request ?? colors.primary),
response: color(colors.response ?? colors.primary),
fragment: color(colors.secondary),
fragmentLabelBg: color(colors.background),
note: color(colors.warning),
noteBg: color(colors.background),
note: color(colors.note ?? colors.warning),
noteBg: color(colors.noteBackground ?? colors.background),
}),
),
height: size.height,
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { blendColor } from "./core/color/style.js"
import { createOpenCodeDiagramPalette } from "./palette.js"
type Rgb = readonly [number, number, number]
@@ -31,11 +32,15 @@ describe("OpenCode diagram palette", () => {
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
const primary = rgb(text)
const info = RGBA.fromInts(40, 120, 220)
const success = RGBA.fromInts(80, 180, 120)
const warning = RGBA.fromInts(220, 160, 80)
const background = RGBA.fromInts(10, 20, 30)
const palette = createOpenCodeDiagramPalette({
text: primary,
subdued: rgb(subdued),
info,
success,
warning,
background,
})
@@ -45,5 +50,9 @@ describe("OpenCode diagram palette", () => {
expect(palette.muted.equals(rgb(muted))).toBe(true)
expect(palette.warning).toBe(info)
expect(palette.background).toBe(background)
expect(palette.request).toBe(success)
expect(palette.response).toBe(warning)
expect(palette.note).toBe(primary)
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
})
})
+6
View File
@@ -5,6 +5,8 @@ export interface OpenCodeDiagramPaletteInput {
readonly text: RGBA
readonly subdued: RGBA
readonly info: RGBA
readonly success: RGBA
readonly warning: RGBA
readonly background: RGBA
}
@@ -16,5 +18,9 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
muted: blendColor(input.text, input.subdued, 0.7),
warning: input.info,
background: input.background,
request: input.success,
response: input.warning,
note: input.text,
noteBackground: blendColor(input.background, input.subdued, 0.25),
}
}
+2
View File
@@ -12,6 +12,8 @@ export default Plugin.define({
text: context.theme.text.default,
subdued: context.theme.text.subdued,
info: context.theme.text.feedback.info.default,
success: context.theme.text.feedback.success.default,
warning: context.theme.text.feedback.warning.default,
background: context.theme.background.default,
}),
})),
+63 -69
View File
@@ -51,16 +51,15 @@ sequenceDiagram
`)
expectDiagram(output).toEqualDiagram(`
╭─────────╮ ╭────────╮
│ Browser │ │ Server │
╰────┬────╯ ╰────┬───╯
│ GET / │
├─────────────────►
│ 401 WWW-Auth │
◄─────────────────┤
│ │
Browser Server
───┬─── ───┬──
│ │
│ GET /
├─────────────────►
│ │
│ 401 WWW-Auth
◄─────────────────┤
│ │
`)
})
@@ -140,13 +139,13 @@ sequenceDiagram
`)
const lines = output.split("\n")
const browserCenter = lines[1]!.indexOf("w")
const serverCenter = lines[1]!.indexOf("v")
const browserCenter = lines[0]!.indexOf("w")
const serverCenter = lines[0]!.indexOf("v")
expect(lines[2]?.[browserCenter]).toBe("┬")
expect(lines[3]?.[browserCenter]).toBe("│")
expect(lines[2]?.[serverCenter]).toBe("┬")
expect(lines[3]?.[serverCenter]).toBe("│")
expect(lines[1]?.[browserCenter]).toBe("┬")
expect(lines[2]?.[browserCenter]).toBe("│")
expect(lines[1]?.[serverCenter]).toBe("┬")
expect(lines[2]?.[serverCenter]).toBe("│")
})
test("ramps participant frames into neutral lifelines", () => {
@@ -277,8 +276,8 @@ sequenceDiagram
A->>B: hello`)
expect(output).not.toContain("<br")
expect(output).toContain("First line")
expect(output).toContain("Second line")
expect(output).toContain("First line")
expect(output).toContain("Second line")
})
test("parses Mermaid arrow head variants", () => {
@@ -314,28 +313,27 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
"╭───╮ ╭───╮
│ A │ │ B │
╰─┬─╯ ╰─┬─╯
│ open solid
├─────────────────>
│ open dashed │
│<─────────────────┤
│ failed solid
├─────────────────✕
│ failed dashed │
│✕─────────────────┤
│ async solid
├─────────────────)
│ async dashed │
│(─────────────────┤
│ │"
" A B
─┬─ ─┬─
│ │
open solid
├─────────────────>
open dashed
│<─────────────────┤
│ │
failed solid
├─────────────────✕
failed dashed
│✕─────────────────┤
│ │
async solid
├─────────────────)
async dashed
│(─────────────────┤
│ │"
`)
})
@@ -387,7 +385,7 @@ sequenceDiagram
end
`)
const lines = output.split("\n")
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
expect(fragmentStart).toBeLessThan(participantCenter)
@@ -557,7 +555,7 @@ sequenceDiagram
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
})
test("keeps long notes inside groups and nested fragment frames intact", () => {
@@ -600,7 +598,7 @@ sequenceDiagram
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
const lines = output.split("\n")
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("")
const externalHeaderLeft = lines[externalLabelRow + 1]!.lastIndexOf("")
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
})
@@ -655,18 +653,17 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
" ╭─ Backend ──────────────────────────────────
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮
│ Browser ││ API │ │ Cache │ │ DB │
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯
│ │ │ │ │
│ GET /users/42 │ │ │
├──────────────────► │ │
│ │
│ get user:42 │
├─────────────────►
│ │ │ │ │ │
╰────────────────────────────────────────────╯"
" ╭─ Backend ───────────────────────────────╮
Browser │ API Cache DB
───┬─── ─┬─ ──┬── ─┬─
│ │ │ │
│ GET /users/42 │ │ │ │ │
├──────────────────► │ │ │
│ │ │ │ │
│ get user:42 │ │ │
├─────────────────► │ │
│ │
╰─────────────────────────────────────────╯"
`)
})
@@ -706,18 +703,17 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
"╭─────────╮
│ Service │
╰────┬────╯
├────────────────────╮
│ Check Permissions │
◄────────────────────╯
│"
"Service
───┬───
├────────────────────╮
│ Check Permissions │
◄────────────────────╯
│"
`)
})
test("frames notes in their reserved rows", () => {
test("renders note badges in their reserved rows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
@@ -729,11 +725,9 @@ sequenceDiagram
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
expect(noteRow).toBeGreaterThan(0)
expect(lines[noteRow - 1]).toContain("")
expect(lines[noteRow - 1]).toContain("")
expect(lines[noteRow]).toContain("│ phase │")
expect(lines[noteRow + 1]).toContain("╰")
expect(lines[noteRow + 1]).toContain("╯")
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
expect(lines[noteRow]).toContain(" phase ")
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
expect(nextMessageRow).toBe(noteRow + 2)
})
+6 -36
View File
@@ -191,29 +191,9 @@ function renderSelfMessage(
}
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
const width = Math.max(...placement.textLines.map(diagramTextWidth))
const left = placement.textX
const right = left + width - 1
const top = placement.textY - 1
const bottom = placement.textY + placement.textLines.length
for (let x = left + 1; x < right; x++) {
setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
}
for (let y = top + 1; y < bottom; y++) {
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
}
setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
for (let y = placement.textY; y < bottom; y++) {
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
}
placement.textLines.forEach((line, index) =>
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
)
}
export function drawSequenceDiagramGrid(
@@ -236,22 +216,12 @@ export function drawSequenceDiagramGrid(
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
} else {
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
)
for (let x = headerLeftX; x <= headerRightX; x++) {
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
}
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
for (let y = participantHeaderY; y < participantRuleY; y++) {
setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
setCell(grid, headerRightX, y, SEQUENCE_BORDER.vertical, "participant")
}
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
}
@@ -206,7 +206,7 @@ ${Array.from(
expect(explicit.activations).toEqual(shorthand.activations)
})
test("centers message label blocks over their arrow span", () => {
test("left-aligns message label blocks inside their arrow span", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
@@ -214,8 +214,6 @@ ${Array.from(
A->>B: short<br/>a much longer line`),
)
const message = plan.steps.find((step) => step.type === "message")!
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
expect(message.labelX).toBe(message.leftX + 2)
})
})
+5 -5
View File
@@ -142,7 +142,7 @@ function messageLabelText(message: SequenceMessage): string {
function participantHeaderWidth(label: string, compact: boolean): number {
const width = labelLinesWidth(mermaidLabelLines(label))
return compact ? width : Math.max(5, width + 4)
return compact ? width : Math.max(3, width)
}
function fragmentLabelText(fragment: SequenceFragment): string {
@@ -247,7 +247,7 @@ function getStepContentBounds(
const leftX = Math.min(fromX, toX)
const rightX = Math.max(fromX, toX)
const labelWidth = messageWidth(step.message)
const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 2)
const labelLeftX = leftX + 2
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
}
if (step.type !== "note") return undefined
@@ -525,8 +525,8 @@ export function createSequencePlacementPlan(
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
)
const participantHeaderTopY = hasGroups ? 1 : 0
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
const participantHeaderY = participantHeaderTopY
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
const lifelineStartY = participantRuleY + 1
const stepStartY = lifelineStartY + 1
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
@@ -650,7 +650,7 @@ export function createSequencePlacementPlan(
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
steps.push({
type: "message",
message: step.message,
+5 -3
View File
@@ -1,11 +1,13 @@
import type { SkillApi } from "@opencode-ai/client/effect/api"
import { Skill } from "@opencode-ai/schema/skill"
import type { Effect } from "effect"
import type { Effect, Types } from "effect"
import type { Transform } from "./registration.js"
export interface SkillDraft {
source(source: Skill.Source): void
list(): readonly Skill.Source[]
list(): readonly Types.DeepMutable<Skill.Info>[]
add(skill: Skill.Info): void
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
remove(id: string): void
}
export interface SkillDomain extends SkillApi<unknown> {
+5 -2
View File
@@ -1,10 +1,13 @@
import type { SkillApi } from "@opencode-ai/client/promise/api"
import type { Skill } from "@opencode-ai/schema/skill"
import type { Transform } from "./registration.js"
import type { DeepMutable } from "./types.js"
export interface SkillDraft {
source(source: Skill.Source): void
list(): readonly Skill.Source[]
list(): readonly DeepMutable<Skill.Info>[]
add(skill: Skill.Info): void
update(id: string, update: (skill: DeepMutable<Skill.Info>) => void): void
remove(id: string): void
}
export interface SkillDomain extends SkillApi {
+1
View File
@@ -46,6 +46,7 @@ import type { ServerOptions } from "./options"
import { modalWorkspaceDriver, provider as modalProvider } from "./workspace/modal-workspace"
const applicationServices = LayerNode.group([
Global.node,
Database.node,
Bus.node,
EventLogger.node,
+5 -2
View File
@@ -72,7 +72,7 @@ import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { sessionTabsFitVertically } from "./ui/layout"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./prompt/history"
@@ -372,7 +372,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode={mode}>
<ThemeProvider
mode={mode}
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
+5 -6
View File
@@ -28,7 +28,6 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor, type Paren
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
import { Global } from "@opencode-ai/util/global"
import { DevTools } from "../devtools"
import { configDirectories } from "../util/config-directories"
@@ -69,15 +68,15 @@ export type ThemeSource = Readonly<{
subscribeRefresh?(refresh: () => void): () => void
}>
const themeSource: ThemeSource = {
export const createThemeSource = (config: string): ThemeSource => ({
async discover() {
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
return discoverThemes(configDirectories(config, process.cwd()))
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
return () => process.off("SIGUSR2", refresh)
},
}
})
export { discoverThemes } from "../theme/discovery"
@@ -139,11 +138,11 @@ subscribeThemes((themes) => setStore("themes", themes))
const themeContext = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => {
init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => {
const renderer = useRenderer()
const configState = useConfig()
const config = configState.data
const themes = props.source ?? themeSource
const themes = props.source
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
+61 -9
View File
@@ -5,6 +5,7 @@ import {
createMemo,
createSignal,
For,
Index,
Match,
on,
onCleanup,
@@ -12,6 +13,7 @@ import {
Show,
Switch,
useContext,
type Accessor,
} from "solid-js"
import path from "node:path"
import { EOL, tmpdir } from "node:os"
@@ -3019,6 +3021,63 @@ function executeCalls(value: unknown): ExecuteCall[] {
})
}
export function executeCallSummary(call: ExecuteCall) {
const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, " ")
return `${call.tool}${call.status === "error" ? " (failed)" : ""}${args ? ` ${args}` : ""}`
}
function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
const theme = useTheme()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
const input = createMemo(() => Object.entries(props.call().input ?? {}))
const expandable = createMemo(() => input().length > 0)
const title = createMemo(() => `${props.call().tool}${props.call().status === "error" ? " (failed)" : ""}`)
return (
<box
paddingLeft={3 + INLINE_TOOL_ICON_WIDTH}
onMouseOver={() => expandable() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (!expandable() || renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
<text
wrapMode="none"
truncate
fg={
props.call().status === "error"
? theme.text.feedback.error.default
: hover()
? theme.text.default
: theme.text.subdued
}
>
{expanded() ? title() : executeCallSummary(props.call())}
</text>
<Show when={expanded()}>
<box paddingLeft={2}>
<For each={input()}>
{([key, value]) => (
<box flexDirection="row">
<text flexShrink={0} fg={theme.text.subdued}>
{key}:{" "}
</text>
<text flexGrow={1} wrapMode="word" fg={theme.text.default}>
{typeof value === "string" ? value : JSON.stringify(value, null, 2)}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
function Execute(props: ToolProps) {
const ctx = use()
@@ -3029,14 +3088,6 @@ function Execute(props: ToolProps) {
const hasRuntimeError = createMemo(() => props.metadata.error === true || props.part.state.status === "error")
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
const showOutput = createMemo(() => output() && hasRuntimeError())
const content = createMemo(() => {
const lines = ["execute"]
for (const call of calls()) {
const args = primitiveInputSummary(call.input ?? {})
lines.push(`${call.tool}${args ? ` ${args}` : ""}${call.status === "error" ? " (failed)" : ""}`)
}
return lines.join("\n")
})
return (
<>
@@ -3048,8 +3099,9 @@ function Execute(props: ToolProps) {
complete={true}
part={props.part}
>
{content()}
execute
</InlineTool>
<Index each={calls()}>{(call) => <ExecuteCallView call={call} />}</Index>
<Show when={showOutput()}>
<box paddingLeft={3}>
<For each={outputPreview().split("\n")}>
@@ -10,6 +10,7 @@ import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { emptyThemeSource } from "../../fixture/fixture"
test("searches settings globally and opens the matching setting", async () => {
let current: Info = {}
@@ -53,7 +54,7 @@ test("searches settings globally and opens the matching setting", async () => {
<TestTuiContexts>
<ConfigProvider config={resolve(current, { terminalSuspend: true })} service={service}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Fixture />
+2 -1
View File
@@ -16,6 +16,7 @@ import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -1979,7 +1980,7 @@ test("keeps shell state scoped to location", async () => {
return (
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
</ThemeProvider>
</Keymap.Provider>
@@ -16,6 +16,7 @@ import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -226,7 +227,7 @@ async function renderOpen(
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Probe />
</DialogProvider>
@@ -5,7 +5,7 @@ import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } from "solid-js"
import { tmpdir } from "../../fixture/fixture"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import type { TuiKeybind } from "../../../src/config/keybind"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -58,7 +58,7 @@ async function mountPrompt(input: {
>
<ConfigProvider config={resolvedConfig}>
<Keymap.Provider>
<ThemeProvider mode="dark">
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Prompt />
@@ -9,7 +9,7 @@ import { dialogWidth } from "../../../src/ui/dialog"
import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select"
import { truncateFilePath } from "../../../src/ui/file-path"
import { stringWidth } from "../../../src/util/string-width"
import { tmpdir } from "../../fixture/fixture"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -62,7 +62,7 @@ async function renderSelect(
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Select />
@@ -138,7 +138,7 @@ async function mountSelect(
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Fixture />
@@ -4,6 +4,7 @@ import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { emptyThemeSource } from "../../fixture/fixture"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ConfigProvider } from "../../../src/config"
@@ -149,7 +150,7 @@ function withTheme(component: () => JSX.Element, onReady = () => {}) {
return (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ThemeProvider mode="dark">
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Ready onReady={onReady}>{component()}</Ready>
</ThemeProvider>
</ConfigProvider>
@@ -12,6 +12,7 @@ import type {
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { emptyThemeSource } from "../../fixture/fixture"
import { ConfigProvider } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
@@ -224,7 +225,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
<ConfigProvider config={config}>
<Keymap.Provider>
<ToastProvider>
<ThemeProvider mode="dark">
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Content />
</DialogProvider>
+2 -2
View File
@@ -10,7 +10,7 @@ import { ThemeProvider } from "../../../src/context/theme"
import { Keymap } from "../../../src/context/keymap"
import { ConfigProvider } from "../../../src/config"
import { ToastProvider } from "../../../src/ui/toast"
import { tmpdir } from "../../fixture/fixture"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
@@ -72,7 +72,7 @@ async function mountForm(root: string, width = 80) {
<ConfigProvider config={config}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<FormPrompt form={form} />
</ToastProvider>
@@ -3,6 +3,7 @@ import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import {
InlineToolRow,
executeCallSummary,
isBackgroundSubagent,
parseApplyPatchFiles,
parseDiagnostics,
@@ -182,6 +183,22 @@ describe("TUI inline tool wrapping", () => {
expect(parseQuestionAnswers({})).toBeUndefined()
})
test("summarizes execute calls on one line", () => {
expect(
executeCallSummary({
tool: "session.prompt",
status: "completed",
input: { sessionID: "ses_example", notify: true },
}),
).toBe("↳ session.prompt [sessionID=ses_example, notify=true]")
expect(executeCallSummary({ tool: "session.get", status: "error", input: { nested: { hidden: true } } })).toBe(
"↳ session.get (failed)",
)
expect(
executeCallSummary({ tool: "session.prompt", status: "completed", input: { text: "first line\nsecond line" } }),
).toBe("↳ session.prompt [text=first line second line]")
})
test("ignores diagnostics with malformed nested ranges", () => {
expect(
parseDiagnostics(
+3
View File
@@ -1,6 +1,9 @@
import { mkdtemp, realpath, rm } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import type { ThemeSource } from "../../src/context/theme"
export const emptyThemeSource: ThemeSource = { discover: () => Promise.resolve({}) }
export async function tmpdir() {
const directory = await realpath(await mkdtemp(path.join(os.tmpdir(), "opencode-tui-test-")))
+20 -20
View File
@@ -1,5 +1,5 @@
import path from "path"
import fs from "fs/promises"
import fs from "fs"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
@@ -13,8 +13,6 @@ const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
const tmp = path.join(os.tmpdir(), app)
await fs.mkdir(tmp, { recursive: true })
const paths = {
get home() {
return process.env.OPENCODE_TEST_HOME ?? os.homedir()
@@ -26,22 +24,13 @@ const paths = {
cache,
config,
state,
tmp: await fs.realpath(tmp),
tmp,
}
export const Path = paths
Flock.setGlobal({ state })
await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
fs.mkdir(Path.repos, { recursive: true }),
])
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
export interface Interface {
@@ -57,13 +46,14 @@ export interface Interface {
}
export function make(input: Partial<Interface> = {}): Interface {
// The acquired service canonicalizes default tmp; use it instead of Path.tmp for path comparisons.
return {
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Path.config,
state: Path.state,
tmp: Path.tmp,
tmp: input.tmp ?? Path.tmp,
bin: Path.bin,
log: Path.log,
repos: Path.repos,
@@ -71,17 +61,27 @@ export function make(input: Partial<Interface> = {}): Interface {
}
}
const acquire = (input: Partial<Interface>) =>
Effect.gen(function* () {
const service = Service.of(make(input))
yield* Effect.promise(() =>
Promise.all(
[service.data, service.config, service.state, service.log, service.bin, service.repos, service.tmp].map(
(directory) => fs.promises.mkdir(directory, { recursive: true }),
),
),
)
const canonicalTmp = yield* Effect.promise(() => fs.promises.realpath(service.tmp))
return Service.of({ ...service, tmp: input.tmp ?? canonicalTmp })
})
const layer = Layer.effect(
Service,
Effect.sync(() => Service.of(make({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config }))),
Effect.suspend(() => acquire({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config })),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,
Effect.sync(() => Service.of(make(input))),
)
export const layerWith = (input: Partial<Interface>) => Layer.effect(Service, acquire(input))
export * as Global from "./global.js"
+6 -2
View File
@@ -1,4 +1,4 @@
import { Formatter, Logger, type LogLevel } from "effect"
import { Effect, FileSystem, Formatter, Logger, type LogLevel } from "effect"
import path from "path"
import { Global } from "../global.js"
import { runID } from "./shared.js"
@@ -53,7 +53,11 @@ export function file(local = true, channel = "local") {
export function fileLogger(target = file(), id: string = runID) {
// Do not set batchWindow to 0; it causes high idle CPU usage.
return Logger.toFile(formatter(id), target, { flag: "a" })
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
return yield* Logger.toFile(formatter(id), target, { flag: "a" })
})
}
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test"
import fs from "fs"
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer } from "effect"
import { Global } from "../src/global.js"
describe("global", () => {
test("importing the module does not create directories", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-import-"))
const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory))
const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href
const result = Bun.spawnSync({
cmd: [process.execPath, "-e", `const { Global } = await import(${JSON.stringify(module)}); void Global.Path.tmp`],
env: {
...process.env,
XDG_DATA_HOME: directories[0],
XDG_CACHE_HOME: directories[1],
XDG_CONFIG_HOME: directories[2],
XDG_STATE_HOME: directories[3],
TMPDIR: directories[4],
},
stderr: "pipe",
})
expect(result.exitCode, result.stderr.toString()).toBe(0)
directories.forEach((directory) => expect(fs.existsSync(path.join(directory, "opencode"))).toBe(false))
fs.rmSync(root, { recursive: true, force: true })
})
test("building layerWith creates service directories and preserves an explicit tmp", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-layer-"))
const directories = {
data: path.join(root, "data"),
config: path.join(root, "config"),
state: path.join(root, "state"),
log: path.join(root, "log"),
bin: path.join(root, "bin"),
repos: path.join(root, "repos"),
tmp: path.join(root, "nested", "..", "tmp"),
}
const context = await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith(directories))))
Object.values(directories).forEach((directory) => expect(fs.statSync(directory).isDirectory()).toBe(true))
expect(Context.get(context, Global.Service).tmp).toBe(directories.tmp)
fs.rmSync(root, { recursive: true, force: true })
})
test("building a layer with default tmp creates and canonicalizes it", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-layer-"))
const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory))
const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href
const result = Bun.spawnSync({
cmd: [
process.execPath,
"-e",
`
import { Context, Effect, Layer } from "effect"
const { Global } = await import(${JSON.stringify(module)})
const context = await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith({}))))
process.stdout.write(Context.get(context, Global.Service).tmp)
`,
],
cwd: path.join(import.meta.dir, ".."),
env: {
...process.env,
XDG_DATA_HOME: directories[0],
XDG_CACHE_HOME: directories[1],
XDG_CONFIG_HOME: directories[2],
XDG_STATE_HOME: directories[3],
TMPDIR: directories[4],
},
stdout: "pipe",
stderr: "pipe",
})
expect(result.exitCode, result.stderr.toString()).toBe(0)
expect(result.stdout.toString()).toBe(fs.realpathSync(path.join(directories[4], "opencode")))
const created = [
path.join(directories[0], "opencode"),
path.join(directories[1], "opencode", "bin"),
path.join(directories[2], "opencode"),
path.join(directories[3], "opencode"),
path.join(directories[0], "opencode", "log"),
path.join(directories[0], "opencode", "repos"),
path.join(directories[4], "opencode"),
]
created.forEach((directory) => expect(fs.statSync(directory).isDirectory()).toBe(true))
fs.rmSync(root, { recursive: true, force: true })
})
})
+75
View File
@@ -0,0 +1,75 @@
import { mkdir } from "node:fs/promises"
import { defineScript, Effect, Llm } from "opencode-drive"
const theme = Bun.env.DRIVE_THEME ?? "opencode"
const output = Bun.env.DRIVE_SCREENSHOT ?? `artifacts/mermaid-${theme}.png`
const animate = Bun.env.DRIVE_ANIMATE === "1"
const cycleThemes = Bun.env.DRIVE_CYCLE_THEMES === "1"
const response = `\`\`\`mermaid
sequenceDiagram
participant B as Browser
participant S as Server
participant T as Ticket store
participant P as PTY
B->>S: GET /
S-->>B: 401 WWW-Auth
Note over B,S: native browser Basic prompt
B->>S: GET / · Basic
S-->>B: 200 web UI
Note over B,S: user opens terminal
B->>S: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
S->>T: issue { ptyID, }
S-->>B: { ticket }
B->>S: WS ?ticket=<br/>Upgrade: websocket
S->>T: consume(token,scope)
T-->>S: ok, delete
S->>P: attach
P-->>B: WS frames
\`\`\``
export default defineScript({
config: {
autoupdate: false,
},
tuiConfig: {
theme: {
name: theme,
mode: "dark",
},
},
tui: {
viewport: { cols: 180, rows: 64 },
},
run: ({ ui, llm }) =>
Effect.gen(function* () {
yield* ui.submit("Show the connection flow as a Mermaid sequence diagram")
yield* llm.send(
Llm.text(response, animate ? { delay: 80, chunkSize: 20 } : { delay: 0, chunkSize: response.length }),
)
yield* ui.waitFor("WS frames", { timeout: 10_000 })
if (cycleThemes) {
yield* Effect.sleep(800)
yield* Effect.forEach(
["everforest", "synthwave84", "matrix", "opencode"],
(next) =>
Effect.gen(function* () {
yield* ui.press("x", { ctrl: true })
yield* ui.press("t")
yield* ui.waitFor("Themes")
yield* ui.type(next)
yield* Effect.sleep(700)
yield* ui.enter()
yield* Effect.sleep(1_200)
}),
{ discard: true },
)
}
const screenshot = yield* ui.screenshot(`mermaid-${theme}`)
yield* Effect.promise(async () => {
await mkdir("artifacts", { recursive: true })
await Bun.write(output, Bun.file(screenshot))
})
yield* Effect.log(`Saved ${output}`)
}),
})