Compare commits

..
Author SHA1 Message Date
Kit Langton 3e506cfbde refactor(core): separate configured command invocation 2026-08-28 13:01:59 -04:00
6 changed files with 658 additions and 529 deletions
+107
View File
@@ -0,0 +1,107 @@
export * as CommandInvocation from "./invocation.js"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/schema/agent"
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { Command } from "../command.js"
import { Location } from "../location.js"
import { ShellSelect } from "../shell/select.js"
// Invocation for configured template commands; source loading and registration stay with the caller.
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
delivery: input.delivery,
})
})
})
function evaluateTemplate(
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+333 -8
View File
@@ -1,15 +1,28 @@
export * as Config from "./config.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, PubSub, Ref, Stream } from "effect"
import type { Document, Entry, Info } from "@opencode-ai/schema/config"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import {
AgentsDirectory,
ClaudeDirectory,
Directory,
Document,
Info,
type Entry,
Event,
} from "@opencode-ai/schema/config"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
import { Watcher } from "./filesystem/watcher.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { ConfigDiscovery } from "./config/discovery.js"
import { AbsolutePath } from "./schema.js"
import { ConfigVariable } from "./config/variable.js"
import { ConfigNormalize } from "./config/normalize.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -21,14 +34,21 @@ export interface Interface {
/** Returns location config documents and discovery sources from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
/**
* Streams raw filesystem updates under config roots from the discovery engine.
* Domain owners filter this feed for the source files they parse and rebuild
* their own state.
* Streams raw filesystem updates under config roots. Config owns root
* topology and watch reconciliation; domain owners filter this feed for the
* source files they parse and rebuild their own state.
*/
readonly changes: () => Stream.Stream<Watcher.Update>
}
export const Options = ConfigDiscovery.Options
export const Options = Schema.Struct({
project: Schema.optional(Schema.Boolean),
// false skips the global config dir, ~/.claude, and ~/.agents; wellknown,
// file, and content entries still load.
global: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
@@ -58,7 +78,312 @@ export const testLayer = (initial: Entry[] = []) =>
}),
)
export const layer = (options?: Options) => Layer.effect(Service, ConfigDiscovery.make(options))
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 watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const fileTargets = new Set<AbsolutePath>()
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) {
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected malformed JSON or JSONC document",
})
return
}
const result = ConfigNormalize.normalize(input)
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
Effect.logWarning("configuration normalization diagnostic", {
source,
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
kind: diagnostic.kind,
action: diagnostic.message,
}),
)
if (result.type === "rejected") return
const info = Option.getOrUndefined(decodeInfo(result.encoded))
if (info) return info
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected canonical configuration after final validation",
})
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (text === undefined) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = yield* parseInfo(substituted, filepath)
if (!info) return
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
})
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
})
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
const entries = yield* wellknown
.entries()
.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
),
)
return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return [
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)),
new Directory({ type: "directory", path: directory }),
]
})
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
const discovered =
locationIsGlobal || options?.project === false
? []
: yield* fs
.up({
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
})
.pipe(Effect.orDie)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below.
const globalRoots = [globalDirectory, globalClaudeDirectory, globalAgentsDirectory].map((item) =>
path.resolve(item),
)
const visible = globalEnabled
? discovered
: discovered.filter((item) => {
const resolved = path.resolve(item)
return !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
})
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const projectDirectories = visible
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory))
const directPaths = visible
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
fileTargets.clear()
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
Effect.orDie,
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
)
const file = options?.file
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
const explicit = file
? yield* loadFile(path.resolve(file)).pipe(
Effect.map((config) => (config ? [config] : [])),
Effect.orDie,
)
: []
const content =
options?.content !== undefined
? yield* ConfigVariable.substitute({
type: "virtual",
source: "OPENCODE_CONFIG_CONTENT",
dir: location.directory,
text: options.content,
}).pipe(
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.orDie,
)
: []
// Global entries sit below explicit and direct files; project
// directories rank above them.
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...globalSupplementary,
...explicit,
...direct,
...projectSupplementary,
...content,
]
})
const initial = yield* discover()
let configs = initial
const updates = yield* PubSub.unbounded<Watcher.Update>()
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
const watched = new Set<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = [
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
...fileTargets,
]
const targets = [
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
for (const target of targets) {
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
const stream = yield* watcher.subscribe(target)
yield* stream.pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
}
})
const reload = Effect.fn("Config.reload")(() =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
}),
),
)
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
reload().pipe(
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
Effect.orElseSucceed(() => false),
),
),
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 minutes").pipe(
Effect.andThen(
Effect.suspend(() => {
if (!wellknown.snapshot().length) return Effect.void
return Effect.gen(function* () {
const changed = yield* wellknown
.refresh()
.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
),
)
if (!changed) yield* reload()
}).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
}),
),
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
return Service.of({
entries: Effect.fnUntraced(function* () {
return configs
}),
changes: () => Stream.fromPubSub(updates),
})
}),
)
export function configured(options?: Options) {
return makeLocationNode({
-337
View File
@@ -1,337 +0,0 @@
export * as ConfigDiscovery from "./discovery.js"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Effect, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import {
AgentsDirectory,
ClaudeDirectory,
Directory,
Document,
Info,
type Entry,
Event,
} from "@opencode-ai/schema/config"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
import { Watcher } from "../filesystem/watcher.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location.js"
import { AbsolutePath } from "../schema.js"
import { ConfigVariable } from "./variable.js"
import { ConfigNormalize } from "./normalize.js"
import { WellKnown } from "../wellknown.js"
export const Options = Schema.Struct({
project: Schema.optional(Schema.Boolean),
// false skips the global config dir, ~/.claude, and ~/.agents; wellknown,
// file, and content entries still load.
global: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
/** Builds the scoped discovery engine without requiring the Config service. */
export const make = Effect.fn("ConfigDiscovery.make")(function* (options?: Options) {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const fileTargets = new Set<AbsolutePath>()
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) {
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected malformed JSON or JSONC document",
})
return
}
const result = ConfigNormalize.normalize(input)
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
Effect.logWarning("configuration normalization diagnostic", {
source,
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
kind: diagnostic.kind,
action: diagnostic.message,
}),
)
if (result.type === "rejected") return
const info = Option.getOrUndefined(decodeInfo(result.encoded))
if (info) return info
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected canonical configuration after final validation",
})
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (text === undefined) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = yield* parseInfo(substituted, filepath)
if (!info) return
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
})
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(Effect.as([] as const)),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
})
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
const entries = yield* wellknown
.entries()
.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
),
)
return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return [
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)),
new Directory({ type: "directory", path: directory }),
]
})
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
const discovered =
locationIsGlobal || options?.project === false
? []
: yield* fs
.up({
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
})
.pipe(Effect.orDie)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below.
const globalRoots = [globalDirectory, globalClaudeDirectory, globalAgentsDirectory].map((item) =>
path.resolve(item),
)
const visible = globalEnabled
? discovered
: discovered.filter((item) => {
const resolved = path.resolve(item)
return !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
})
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const projectDirectories = visible
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory))
const directPaths = visible
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
fileTargets.clear()
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
Effect.orDie,
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
)
const file = options?.file
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
const explicit = file
? yield* loadFile(path.resolve(file)).pipe(
Effect.map((config) => (config ? [config] : [])),
Effect.orDie,
)
: []
const content =
options?.content !== undefined
? yield* ConfigVariable.substitute({
type: "virtual",
source: "OPENCODE_CONFIG_CONTENT",
dir: location.directory,
text: options.content,
}).pipe(
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.orDie,
)
: []
// Global entries sit below explicit and direct files; project
// directories rank above them.
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...globalSupplementary,
...explicit,
...direct,
...projectSupplementary,
...content,
]
})
const initial = yield* discover()
let configs = initial
const updates = yield* PubSub.unbounded<Watcher.Update>()
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
const watched = new Set<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = [
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
...fileTargets,
]
const targets = [
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
for (const target of targets) {
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
const stream = yield* watcher.subscribe(target)
yield* stream.pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
}
})
const reload = Effect.fn("Config.reload")(() =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
}),
),
)
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
reload().pipe(
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
Effect.orElseSucceed(() => false),
),
),
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 minutes").pipe(
Effect.andThen(
Effect.suspend(() => {
if (!wellknown.snapshot().length) return Effect.void
return Effect.gen(function* () {
const changed = yield* wellknown
.refresh()
.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
),
)
if (!changed) yield* reload()
}).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
}),
),
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
return {
entries: Effect.fnUntraced(function* () {
return configs
}),
changes: () => Stream.fromPubSub(updates),
}
})
+3 -106
View File
@@ -1,18 +1,12 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { CommandInvocation } from "../../command/invocation.js"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -29,9 +23,7 @@ export const Plugin = define({
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const invoke = yield* CommandInvocation.make(ctx)
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -63,38 +55,7 @@ export const Plugin = define({
draft.add({
name,
description: command.description,
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
config,
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
execute: (input) => invoke(command, input),
})
}
}
@@ -147,67 +108,3 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
@@ -0,0 +1,215 @@
import { describe, expect } from "bun:test"
import path from "path"
import { DateTime, Effect, Layer } from "effect"
import { CommandInvocation } from "@opencode-ai/core/command/invocation"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Agent } from "@opencode-ai/schema/agent"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { AppProcess } from "@opencode-ai/util/process"
import { tempLocationLayer } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const shell = ShellSelect.Service.of({
resolve: (input) =>
Effect.sync(() => {
expect(input).toEqual({ priority: "config" })
return "sh"
}),
transform: () => Effect.die("unused shell.transform"),
reload: () => Effect.die("unused shell.reload"),
})
const it = testEffect(
Layer.mergeAll(AppNodeBuilder.build(AppProcess.node), tempLocationLayer, Layer.succeed(ShellSelect.Service, shell)),
)
const sessionID = Session.ID.make("ses_command_invocation")
describe("CommandInvocation", () => {
it.effect("expands arguments without changing unconfigured session defaults or prompt attachments", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const invoke = yield* CommandInvocation.make(promptHost(prompts))
const files = [{ uri: "file:///context.md", name: "context" }]
for (const [template, text, expected] of [
[
"$2 / $1 / $2",
`"alpha beta" 'gamma delta' [Image 3] tail`,
"gamma delta [Image 3] tail / alpha beta / gamma delta [Image 3] tail",
],
["[$1][$3]", "one two", "[one][]"],
["raw [$ARGUMENTS]", `"alpha beta" 'gamma delta'`, `raw ["alpha beta" 'gamma delta']`],
[" Review ", " details ", "Review \n\n details"],
[" Review ", " ", "Review"],
]) {
expect(
yield* invoke(new ConfigCommand.Info({ template }), {
sessionID,
prompt: { text, files },
delivery: "queue",
}),
).toBeUndefined()
expect(prompts.at(-1)).toEqual({ sessionID, text: expected, files, delivery: "queue" })
}
}),
)
it.effect("switches agents before applying command or agent model defaults and admitting the prompt", () =>
Effect.gen(function* () {
const calls: unknown[] = []
const ctx = promptHost(calls)
const location = yield* Location.Service
const reviewer = Agent.ID.make("reviewer")
const agentModel = { id: Model.ID.make("agent-model"), providerID: Provider.ID.make("example") }
const commandModel = {
model: Model.ID.make("command-model"),
providerID: Provider.ID.make("example"),
variant: Model.VariantID.make("careful"),
}
const session = Session.Info.make({
id: sessionID,
projectID: location.project.id,
agent: Agent.ID.make("build"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: location.directory },
})
for (const testCase of [
{
currentAgent: session.agent,
agentModel,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer, model: commandModel }),
expected: [
["session.get", { sessionID }],
["switchAgent", { sessionID, agent: reviewer }],
["agent.get", { agentID: reviewer }],
["switchModel", { sessionID, model: { id: "command-model", providerID: "example", variant: "careful" } }],
],
},
{
currentAgent: reviewer,
agentModel,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
expected: [
["session.get", { sessionID }],
["agent.get", { agentID: reviewer }],
["switchModel", { sessionID, model: agentModel }],
],
},
{
currentAgent: session.agent,
agentModel: undefined,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
expected: [
["session.get", { sessionID }],
["switchAgent", { sessionID, agent: reviewer }],
["agent.get", { agentID: reviewer }],
],
},
{
currentAgent: session.agent,
agentModel,
command: new ConfigCommand.Info({
template: "Review",
model: { model: commandModel.model, providerID: commandModel.providerID },
}),
expected: [["switchModel", { sessionID, model: { id: "command-model", providerID: "example" } }]],
},
]) {
calls.length = 0
const invoke = yield* CommandInvocation.make(
host({
agent: {
...ctx.agent,
get: (input) =>
Effect.sync(() => {
calls.push(["agent.get", input])
return { location, data: { ...Agent.Info.default(reviewer), model: testCase.agentModel } }
}),
},
session: {
...ctx.session,
get: (input) =>
Effect.sync(() => {
calls.push(["session.get", input])
return { ...session, agent: testCase.currentAgent }
}),
switchAgent: (input) => Effect.sync(() => calls.push(["switchAgent", input])),
switchModel: (input) => Effect.sync(() => calls.push(["switchModel", input])),
},
}),
)
yield* invoke(testCase.command, {
sessionID,
prompt: { text: "" },
delivery: "steer",
})
expect(calls).toEqual([...testCase.expected, { sessionID, text: "Review", delivery: "steer" }])
}
}),
)
it.live("interpolates in source order using the location, closed stdin and nonzero-exit output", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const location = yield* Location.Service
yield* Effect.promise(() => Bun.write(path.join(location.directory, "context.txt"), "context"))
const invoke = yield* CommandInvocation.make(promptHost(prompts))
yield* invoke(
new ConfigCommand.Info({
template:
'first=!`read value || printf closed-; cat context.txt; sleep 0.05; printf "%s" "-stderr" >&2; exit 7`; second=!`printf "%s" "$1"`',
}),
{ sessionID, prompt: { text: "argument" }, delivery: "steer" },
)
expect(prompts).toEqual([{ sessionID, text: "first=closed-context-stderr; second=argument", delivery: "steer" }])
}),
)
it.live("wraps process failures with the shell source and does not admit a prompt", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const location = yield* Location.Service
const missing = path.join(location.directory, "missing-shell")
const invoke = yield* CommandInvocation.make(promptHost(prompts)).pipe(
Effect.provideService(ShellSelect.Service, { ...shell, resolve: () => Effect.succeed(missing) }),
)
const error = yield* invoke(new ConfigCommand.Info({ template: '!`printf "hello"`' }), {
sessionID,
prompt: { text: "" },
delivery: "steer",
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(Error)
expect(String(error)).toContain('Shell interpolation failed for "printf \\"hello\\"": Command failed:')
expect(String(error)).toContain(missing)
expect(prompts).toEqual([])
}),
)
})
function promptHost(prompts: unknown[]) {
return host({
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push(input)
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_command_invocation"),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
})
}),
},
})
}
@@ -1,78 +0,0 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/core/config"
import { ConfigDiscovery } from "@opencode-ai/core/config/discovery"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { Location } from "@opencode-ai/core/location"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Event } from "@opencode-ai/schema/config"
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 { Effect, Fiber, Layer, Stream } from "effect"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { location } from "../fixture/location"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node, Watcher.node, Credential.node, WellKnown.node]), [
[Watcher.node, Watcher.testLayer],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
]).pipe(Layer.merge(Watcher.testLayer)),
)
describe("ConfigDiscovery", () => {
it.live("discovers and refreshes ordered entries without the Config service", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "custom.jsonc")
yield* fs.writeFileString(target, '{ "shell": "before" }')
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
const discovery: Config.Interface = yield* ConfigDiscovery.make({
project: false,
global: false,
file: target,
content: '{ "shell": "inline" }',
})
expect(Config.Options).toBe(ConfigDiscovery.Options)
expect(yield* discovery.entries()).toMatchObject([
{ type: "document", path: target, info: { shell: "before" } },
{ type: "document", info: { shell: "inline" } },
])
expect(yield* watcher.subscriptions()).toEqual([{ path: target, type: "file" }])
const updated = yield* bus.subscribe(Event.Updated).pipe(
Stream.take(1),
Stream.mapEffect(() => discovery.entries()),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const changed = yield* discovery
.changes()
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
yield* fs.writeFileString(target, '{ "shell": "after" }')
expect((yield* discovery.entries())[0]).toMatchObject({ info: { shell: "before" } })
yield* watcher.emit({ path: target, type: "update" })
expect(yield* Fiber.join(changed)).toEqual([{ path: target, type: "update" }])
// The update event is a read barrier: its subscriber sees refreshed entries.
expect(yield* Fiber.join(updated)).toMatchObject([
[
{ type: "document", path: target, info: { shell: "after" } },
{ type: "document", info: { shell: "inline" } },
],
])
}).pipe(
Effect.provideService(Location.Service, location({ directory: AbsolutePath.make(tmp.path) })),
Effect.provideService(Global.Service, Global.make({ config: path.join(tmp.path, "global"), home: tmp.path })),
)
}),
)
})