mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 04:56:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e506cfbde |
@@ -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
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user