feat(plugin): add session request hook (#35794)
This commit is contained in:
parent
e25a724bc2
commit
4f976bcf1a
124 changed files with 7743 additions and 5620 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "directory-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "folder-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
|
|
@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => {
|
|||
it.live("loads user plugins before internal post plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
|
|
@ -275,7 +275,7 @@ function mutablePlugin(description: string) {
|
|||
return `
|
||||
import { define } from ${JSON.stringify(plugin)}
|
||||
|
||||
export default define({
|
||||
export default EffectPlugin.define({
|
||||
id: "mutable-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, type Scope } from "effect"
|
||||
|
||||
export const toolIdentity = {
|
||||
|
|
@ -66,12 +66,10 @@ export const registerToolPlugin = <R>(plugin: {
|
|||
registrations,
|
||||
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
|
||||
{ discard: true },
|
||||
)
|
||||
).pipe(Effect.orDie)
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
execute: {
|
||||
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
}
|
||||
yield* plugin.effect(context as PluginContext)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
|
|||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -44,7 +44,7 @@ describe("LocationServiceMap", () => {
|
|||
const sdk = yield* SdkPlugins.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const id = AgentV2.ID.make("persistent-sdk-agent")
|
||||
const plugin = define({
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "persistent-sdk-plugin",
|
||||
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
|
||||
})
|
||||
|
|
@ -432,7 +432,7 @@ describe("LocationServiceMap", () => {
|
|||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const reviewer = define({
|
||||
const reviewer = EffectPlugin.define({
|
||||
id: "reviewer",
|
||||
effect: (ctx) =>
|
||||
ctx.agent
|
||||
|
|
|
|||
47
packages/core/test/plugin-hooks.test.ts
Normal file
47
packages/core/test/plugin-hooks.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/llm"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { PluginHooks } from "../src/plugin/hooks"
|
||||
|
||||
describe("PluginHooks", () => {
|
||||
it("registers scoped domain hooks and triggers them sequentially", async () => {
|
||||
const seen: string[] = []
|
||||
const program = Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push("first")
|
||||
event.system.push(SystemPart.make("second"))
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(event.system[1]?.text ?? "missing")
|
||||
event.messages = [Message.user("changed")]
|
||||
}),
|
||||
)
|
||||
const event = {
|
||||
sessionID: Session.ID.make("ses_hooks"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
system: [SystemPart.make("first")],
|
||||
messages: [Message.user("original")],
|
||||
tools: {},
|
||||
}
|
||||
|
||||
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
|
||||
expect(seen).toEqual(["first", "second"])
|
||||
expect(event.messages).toEqual([Message.user("changed")])
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(program).pipe(
|
||||
Effect.provide(PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
|
|
@ -49,7 +49,7 @@ describe("PluginV2", () => {
|
|||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const managed = () =>
|
||||
define({
|
||||
EffectPlugin.define({
|
||||
id: "managed",
|
||||
effect: (ctx) =>
|
||||
ctx.agent
|
||||
|
|
@ -97,25 +97,40 @@ describe("PluginV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("retries the same generation after materialization fails", () =>
|
||||
it.effect("skips failed plugins and loads the rest", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
let fail = true
|
||||
const plugin = define({
|
||||
id: "retry",
|
||||
const good = EffectPlugin.define({
|
||||
id: "good",
|
||||
effect: (ctx) =>
|
||||
ctx.agent
|
||||
.transform(() => {
|
||||
if (fail) throw new Error("materialization failed")
|
||||
})
|
||||
.transform((agents) =>
|
||||
agents.update("configured", (agent) => {
|
||||
agent.description = "loaded"
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
const bad = EffectPlugin.define({
|
||||
id: "bad",
|
||||
effect: () => {
|
||||
if (fail) return Effect.die(new Error("materialization failed"))
|
||||
return Effect.void
|
||||
},
|
||||
})
|
||||
|
||||
yield* plugins.activate([{ plugin: good }, { plugin: bad }])
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded")
|
||||
|
||||
expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true)
|
||||
fail = false
|
||||
yield* plugins.activate([{ plugin }])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }])
|
||||
yield* plugins.activate([{ plugin: good }, { plugin: bad }])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("good") },
|
||||
{ id: Plugin.ID.make("bad") },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -142,7 +157,7 @@ describe("PluginV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
let visible = true
|
||||
const plugin = define({
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "isolated",
|
||||
effect: () =>
|
||||
Effect.serviceOption(Secret).pipe(
|
||||
|
|
@ -161,7 +176,7 @@ describe("PluginV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const plugin = define({
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "tool-plugin",
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
|
|
@ -202,7 +217,7 @@ describe("PluginV2", () => {
|
|||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
})
|
||||
const plugin = define({
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "grouped-tools",
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
|
|
@ -234,7 +249,7 @@ describe("PluginV2", () => {
|
|||
after?: { input: unknown; result: unknown; output: unknown }
|
||||
} = {}
|
||||
|
||||
const plugin = define({
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "tool-hooks",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -252,19 +267,23 @@ describe("PluginV2", () => {
|
|||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.tool.execute
|
||||
.before((event) => {
|
||||
seen.before = event.input
|
||||
event.input = { text: "before-mutated" }
|
||||
})
|
||||
yield* ctx.tool
|
||||
.hook("execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.before = event.input
|
||||
event.input = { text: "before-mutated" }
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.asVoid)
|
||||
|
||||
yield* ctx.tool.execute
|
||||
.after((event) => {
|
||||
seen.after = { input: event.input, result: event.result, output: event.output }
|
||||
event.result = { type: "text", value: "after-mutated" }
|
||||
event.output = { structured: { rewritten: true }, content: [] }
|
||||
})
|
||||
yield* ctx.tool
|
||||
.hook("execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.after = { input: event.input, result: event.result, output: event.output }
|
||||
event.result = { type: "text", value: "after-mutated" }
|
||||
event.output = { structured: { rewritten: true }, content: [] }
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.asVoid)
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Integration } from "@opencode-ai/core/integration"
|
|||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "config-effect-plugin",
|
||||
effect: (ctx) =>
|
||||
ctx.agent
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "config-promise-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "failing-plugin",
|
||||
effect: () => Effect.die("plugin failed"),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "variant-source",
|
||||
effect: (ctx) =>
|
||||
ctx.catalog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
|
|
@ -19,8 +19,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
reload: () => Effect.die("unused agent.reload"),
|
||||
},
|
||||
aisdk: overrides.aisdk ?? {
|
||||
sdk: () => Effect.die("unused aisdk.sdk"),
|
||||
language: () => Effect.die("unused aisdk.language"),
|
||||
hook: () => Effect.die("unused aisdk.hook"),
|
||||
},
|
||||
catalog: overrides.catalog ?? {
|
||||
provider: {
|
||||
|
|
@ -45,11 +44,15 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
connectKey: () => Effect.die("unused integration.connectKey"),
|
||||
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
||||
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
||||
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
|
||||
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
|
||||
connect: {
|
||||
key: () => Effect.die("unused integration.connect.key"),
|
||||
oauth: () => Effect.die("unused integration.connect.oauth"),
|
||||
},
|
||||
attempt: {
|
||||
status: () => Effect.die("unused integration.attempt.status"),
|
||||
complete: () => Effect.die("unused integration.attempt.complete"),
|
||||
cancel: () => Effect.die("unused integration.attempt.cancel"),
|
||||
},
|
||||
transform: () => Effect.die("unused integration.transform"),
|
||||
reload: () => Effect.die("unused integration.reload"),
|
||||
connection: {
|
||||
|
|
@ -72,10 +75,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
},
|
||||
tool: overrides.tool ?? {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
execute: {
|
||||
before: () => Effect.die("unused tool.execute.before"),
|
||||
after: () => Effect.die("unused tool.execute.after"),
|
||||
},
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
session: overrides.session ?? {
|
||||
create: () => Effect.die("unused session.create"),
|
||||
|
|
@ -83,6 +83,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
prompt: () => Effect.die("unused session.prompt"),
|
||||
command: () => Effect.die("unused session.command"),
|
||||
interrupt: () => Effect.die("unused session.interrupt"),
|
||||
hook: () => Effect.die("unused session.hook"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -188,11 +189,15 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
|||
return {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
connectKey: () => Effect.die("unused integration.connectKey"),
|
||||
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
||||
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
||||
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
|
||||
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
|
||||
connect: {
|
||||
key: () => Effect.die("unused integration.connect.key"),
|
||||
oauth: () => Effect.die("unused integration.connect.oauth"),
|
||||
},
|
||||
attempt: {
|
||||
status: () => Effect.die("unused integration.attempt.status"),
|
||||
complete: () => Effect.die("unused integration.attempt.complete"),
|
||||
cancel: () => Effect.die("unused integration.attempt.cancel"),
|
||||
},
|
||||
reload: integration.reload,
|
||||
connection: {
|
||||
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
|||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { define } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ describe("fromPromise", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const seen: string[] = []
|
||||
const promisePlugin = define({
|
||||
const promisePlugin = Plugin.define({
|
||||
id: "promise-client-reads",
|
||||
setup: async (ctx) => {
|
||||
const results = await Promise.all([
|
||||
|
|
@ -46,7 +46,7 @@ describe("fromPromise", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
|
||||
const promisePlugin = define({
|
||||
const promisePlugin = Plugin.define({
|
||||
id: "promise-example",
|
||||
setup: async (ctx) => {
|
||||
expect(ctx.options.mode).toBe("strict")
|
||||
|
|
@ -75,7 +75,7 @@ describe("fromPromise", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
|
||||
const promisePlugin = define({
|
||||
const promisePlugin = Plugin.define({
|
||||
id: "promise-dispose",
|
||||
setup: async (ctx) => {
|
||||
const registration = await ctx.agent.transform((draft) => {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("selects one edit tool family for each model", () =>
|
||||
it.effect("materializes all permission-eligible edit tools before request policy", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
|
|
@ -96,10 +96,13 @@ describe("ToolRegistry", () => {
|
|||
.materialize({ model })
|
||||
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
|
||||
|
||||
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "patch"])
|
||||
expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "patch"])
|
||||
expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "patch"])
|
||||
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"])
|
||||
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
|
||||
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([
|
||||
"read",
|
||||
"edit",
|
||||
"write",
|
||||
"patch",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue