host.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import { Plugin } from "@opencode-ai/plugin/effect"
  2. import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
  3. import { Agent } from "@opencode-ai/core/agent"
  4. import { Catalog } from "@opencode-ai/core/catalog"
  5. import { Credential } from "@opencode-ai/core/credential"
  6. import { Integration } from "@opencode-ai/core/integration"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { Model } from "@opencode-ai/core/model"
  9. import { Project } from "@opencode-ai/core/project"
  10. import { Provider } from "@opencode-ai/core/provider"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { WebSearch } from "@opencode-ai/core/websearch"
  13. import { Effect, Stream } from "effect"
  14. type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
  15. readonly session?: Partial<Plugin.Context["session"]>
  16. }
  17. export function host(overrides: Overrides = {}): Plugin.Context {
  18. return {
  19. app: overrides.app ?? { name: "test", version: "test", channel: "test" },
  20. options: {},
  21. agent: overrides.agent ?? {
  22. get: () => Effect.die("unused agent.get"),
  23. list: () => Effect.die("unused agent.list"),
  24. transform: () => Effect.die("unused agent.transform"),
  25. reload: () => Effect.die("unused agent.reload"),
  26. },
  27. aisdk: overrides.aisdk ?? {
  28. hook: () => Effect.die("unused aisdk.hook"),
  29. },
  30. provider: overrides.provider ?? {
  31. hook: () => Effect.die("unused provider.hook"),
  32. },
  33. catalog: overrides.catalog ?? {
  34. provider: {
  35. list: () => Effect.die("unused catalog.provider.list"),
  36. get: () => Effect.die("unused catalog.provider.get"),
  37. },
  38. model: {
  39. list: () => Effect.die("unused catalog.model.list"),
  40. default: () => Effect.die("unused catalog.model.default"),
  41. },
  42. transform: () => Effect.die("unused catalog.transform"),
  43. reload: () => Effect.die("unused catalog.reload"),
  44. },
  45. command: overrides.command ?? {
  46. list: () => Effect.die("unused command.list"),
  47. transform: () => Effect.die("unused command.transform"),
  48. reload: () => Effect.die("unused command.reload"),
  49. },
  50. event: overrides.event ?? {
  51. subscribe: () => Stream.empty,
  52. },
  53. integration: overrides.integration ?? {
  54. list: () => Effect.die("unused integration.list"),
  55. get: () => Effect.die("unused integration.get"),
  56. connect: {
  57. key: () => Effect.die("unused integration.connect.key"),
  58. },
  59. oauth: {
  60. connect: () => Effect.die("unused integration.oauth.connect"),
  61. status: () => Effect.die("unused integration.oauth.status"),
  62. complete: () => Effect.die("unused integration.oauth.complete"),
  63. cancel: () => Effect.die("unused integration.oauth.cancel"),
  64. },
  65. command: {
  66. connect: () => Effect.die("unused integration.command.connect"),
  67. status: () => Effect.die("unused integration.command.status"),
  68. cancel: () => Effect.die("unused integration.command.cancel"),
  69. },
  70. transform: () => Effect.die("unused integration.transform"),
  71. reload: () => Effect.die("unused integration.reload"),
  72. connection: {
  73. active: () => Effect.die("unused integration.connection.active"),
  74. resolve: () => Effect.die("unused integration.connection.resolve"),
  75. },
  76. },
  77. plugin: overrides.plugin ?? {
  78. list: () => Effect.die("unused plugin.list"),
  79. },
  80. reference: overrides.reference ?? {
  81. list: () => Effect.die("unused reference.list"),
  82. transform: () => Effect.die("unused reference.transform"),
  83. reload: () => Effect.die("unused reference.reload"),
  84. },
  85. skill: overrides.skill ?? {
  86. list: () => Effect.die("unused skill.list"),
  87. transform: () => Effect.die("unused skill.transform"),
  88. reload: () => Effect.die("unused skill.reload"),
  89. },
  90. shell: overrides.shell ?? {
  91. hook: () => Effect.die("unused shell.hook"),
  92. },
  93. tool: overrides.tool ?? {
  94. transform: () => Effect.die("unused tool.transform"),
  95. hook: () => Effect.die("unused tool.hook"),
  96. },
  97. websearch: overrides.websearch ?? {
  98. providers: () => Effect.die("unused websearch.providers"),
  99. query: () => Effect.die("unused websearch.query"),
  100. transform: () => Effect.die("unused websearch.transform"),
  101. reload: () => Effect.die("unused websearch.reload"),
  102. },
  103. session: {
  104. hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
  105. create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
  106. get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
  107. prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
  108. generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
  109. command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
  110. rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
  111. synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
  112. interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
  113. wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
  114. },
  115. }
  116. }
  117. export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
  118. return {
  119. get: (input) =>
  120. agent.get(input.agentID).pipe(
  121. Effect.flatMap((value) =>
  122. value
  123. ? Effect.succeed({
  124. location: new Location.Info({
  125. directory: AbsolutePath.make("/"),
  126. project: {
  127. id: Project.ID.make("test"),
  128. directory: AbsolutePath.make("/"),
  129. canonical: AbsolutePath.make("/"),
  130. },
  131. }),
  132. data: agentInfo(value),
  133. })
  134. : Effect.fail(new Error(`Agent not found: ${input.agentID}`)),
  135. ),
  136. ),
  137. list: () => Effect.die("unused agent.list"),
  138. reload: agent.reload,
  139. transform: (callback) =>
  140. agent.transform((draft) =>
  141. callback({
  142. list: () => draft.list().map(agentInfo),
  143. get: (id) => {
  144. const value = draft.get(Agent.ID.make(id))
  145. return value && agentInfo(value)
  146. },
  147. default: (id) => draft.default(id === undefined ? undefined : Agent.ID.make(id)),
  148. update: (id, update) =>
  149. draft.update(Agent.ID.make(id), (value) => {
  150. const current = agentInfo(value)
  151. update(current)
  152. Object.assign(value, current, { id: Agent.ID.make(current.id) })
  153. }),
  154. remove: (id) => draft.remove(Agent.ID.make(id)),
  155. }),
  156. ),
  157. }
  158. }
  159. export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] {
  160. return {
  161. provider: {
  162. list: () => Effect.die("unused catalog.provider.list"),
  163. get: () => Effect.die("unused catalog.provider.get"),
  164. },
  165. model: {
  166. list: () =>
  167. catalog.model.available().pipe(
  168. Effect.map((data) => ({
  169. location: new Location.Info({
  170. directory: AbsolutePath.make("/"),
  171. project: {
  172. id: Project.ID.make("test"),
  173. directory: AbsolutePath.make("/"),
  174. canonical: AbsolutePath.make("/"),
  175. },
  176. }),
  177. data: data.map(modelInfo),
  178. })),
  179. ),
  180. default: () => Effect.die("unused catalog.model.default"),
  181. },
  182. reload: catalog.reload,
  183. transform: (callback) =>
  184. catalog.transform((draft) =>
  185. callback({
  186. provider: {
  187. list: () =>
  188. draft.provider.list().map((value) => ({
  189. provider: providerInfo(value.provider),
  190. models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])),
  191. })),
  192. get: (id) => {
  193. const value = draft.provider.get(Provider.ID.make(id))
  194. return (
  195. value && {
  196. provider: providerInfo(value.provider),
  197. models: new Map(Array.from(value.models, ([id, model]) => [id, modelInfo(model)])),
  198. }
  199. )
  200. },
  201. update: (id, update) =>
  202. draft.provider.update(Provider.ID.make(id), (value) => {
  203. const current = providerInfo(value)
  204. update(current)
  205. Object.assign(value, current, { id: Provider.ID.make(current.id) })
  206. }),
  207. remove: (id) => draft.provider.remove(Provider.ID.make(id)),
  208. },
  209. model: {
  210. get: (providerID, modelID) => {
  211. const value = draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))
  212. return value && modelInfo(value)
  213. },
  214. update: (providerID, modelID, update) =>
  215. draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), (value) => {
  216. const current = modelInfo(value)
  217. update(current)
  218. Object.assign(value, current, {
  219. id: Model.ID.make(current.id),
  220. providerID: Provider.ID.make(current.providerID),
  221. family: current.family === undefined ? undefined : Model.Family.make(current.family),
  222. variants: current.variants?.map((variant) => ({
  223. ...variant,
  224. id: Model.VariantID.make(variant.id),
  225. })),
  226. })
  227. }),
  228. remove: (providerID, modelID) =>
  229. draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
  230. default: {
  231. get: () => {
  232. const value = draft.model.default.get()
  233. return value && { providerID: value.providerID, modelID: value.modelID }
  234. },
  235. set: (providerID, modelID) =>
  236. draft.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
  237. },
  238. },
  239. }),
  240. ),
  241. }
  242. }
  243. export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] {
  244. return {
  245. list: () => Effect.die("unused integration.list"),
  246. get: () => Effect.die("unused integration.get"),
  247. connect: {
  248. key: () => Effect.die("unused integration.connect.key"),
  249. },
  250. oauth: {
  251. connect: () => Effect.die("unused integration.oauth.connect"),
  252. status: () => Effect.die("unused integration.oauth.status"),
  253. complete: () => Effect.die("unused integration.oauth.complete"),
  254. cancel: () => Effect.die("unused integration.oauth.cancel"),
  255. },
  256. command: {
  257. connect: () => Effect.die("unused integration.command.connect"),
  258. status: () => Effect.die("unused integration.command.status"),
  259. cancel: () => Effect.die("unused integration.command.cancel"),
  260. },
  261. reload: integration.reload,
  262. connection: {
  263. active: (id) => integration.connection.active(Integration.ID.make(id)),
  264. resolve: (connection) =>
  265. integration.connection.resolve(
  266. connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
  267. ),
  268. },
  269. transform: (callback) =>
  270. integration.transform((draft) =>
  271. callback({
  272. list: () => draft.list().map((value) => ({ id: value.id, name: value.name })),
  273. get: (id) => {
  274. const value = draft.get(Integration.ID.make(id))
  275. return value && { id: value.id, name: value.name }
  276. },
  277. update: (id, update) => draft.update(Integration.ID.make(id), update),
  278. remove: (id) => draft.remove(Integration.ID.make(id)),
  279. method: {
  280. list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
  281. update: (input) => {
  282. if ("authorize" in input) {
  283. const methodID = Integration.MethodID.make(input.method.id)
  284. const refresh = input.refresh
  285. draft.method.update({
  286. integrationID: Integration.ID.make(input.integrationID),
  287. method: { ...input.method, id: methodID },
  288. authorize: (inputs) =>
  289. input.authorize(inputs).pipe(
  290. Effect.map((authorization) => {
  291. if (authorization.mode === "auto") {
  292. return {
  293. ...authorization,
  294. callback: authorization.callback.pipe(
  295. Effect.map((credential) =>
  296. Credential.OAuth.make({
  297. ...credential,
  298. methodID: Integration.MethodID.make(credential.methodID),
  299. }),
  300. ),
  301. ),
  302. }
  303. }
  304. return {
  305. ...authorization,
  306. callback: (code: string) =>
  307. authorization.callback(code).pipe(
  308. Effect.map((credential) =>
  309. Credential.OAuth.make({
  310. ...credential,
  311. methodID: Integration.MethodID.make(credential.methodID),
  312. }),
  313. ),
  314. ),
  315. }
  316. }),
  317. ),
  318. ...(refresh
  319. ? {
  320. refresh: (value: Credential.OAuth) =>
  321. refresh(value).pipe(
  322. Effect.map((next) =>
  323. Credential.OAuth.make({
  324. ...next,
  325. methodID: Integration.MethodID.make(next.methodID),
  326. }),
  327. ),
  328. ),
  329. }
  330. : {}),
  331. ...(input.label ? { label: input.label } : {}),
  332. })
  333. return
  334. }
  335. if (input.method.type === "env") {
  336. draft.method.update({
  337. integrationID: Integration.ID.make(input.integrationID),
  338. method: { ...input.method, names: [...input.method.names] },
  339. })
  340. return
  341. }
  342. if (input.method.type === "command") {
  343. draft.method.update({
  344. integrationID: Integration.ID.make(input.integrationID),
  345. method: {
  346. ...input.method,
  347. id: Integration.MethodID.make(input.method.id),
  348. command: [...input.method.command],
  349. },
  350. })
  351. return
  352. }
  353. draft.method.update({
  354. integrationID: Integration.ID.make(input.integrationID),
  355. method: input.method,
  356. })
  357. },
  358. remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
  359. },
  360. }),
  361. ),
  362. }
  363. }
  364. export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] {
  365. const location = Location.Info.make({
  366. directory: AbsolutePath.make("/tmp/websearch-test"),
  367. project: {
  368. id: Project.ID.make("websearch-test"),
  369. directory: AbsolutePath.make("/tmp/websearch-test"),
  370. canonical: AbsolutePath.make("/tmp/websearch-test"),
  371. },
  372. })
  373. return {
  374. providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))),
  375. query: (input) =>
  376. websearch
  377. .query({ query: input.query, providerID: input.providerID && WebSearch.ID.make(input.providerID) })
  378. .pipe(Effect.map((data) => ({ location, data }))),
  379. reload: websearch.reload,
  380. transform: (callback) =>
  381. websearch.transform((draft) => {
  382. callback({
  383. add: (definition) =>
  384. draft.add({
  385. id: WebSearch.ID.make(definition.id),
  386. name: definition.name,
  387. execute: definition.execute,
  388. }),
  389. default: {
  390. get: draft.default.get,
  391. set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
  392. },
  393. })
  394. }),
  395. }
  396. }
  397. function oauthCredential(value: Credential.OAuth) {
  398. return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
  399. }
  400. function method(value: Integration.Method) {
  401. if (value.type === "env") return { type: value.type, names: [...value.names] }
  402. if (value.type === "key") return { type: value.type, label: value.label }
  403. if (value.type === "command") return { ...value, command: [...value.command] }
  404. return {
  405. type: value.type,
  406. id: value.id,
  407. label: value.label,
  408. prompts: value.prompts?.map((prompt) => {
  409. if (prompt.type === "text") return { ...prompt }
  410. return { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
  411. }),
  412. }
  413. }
  414. function internalMethod(
  415. value: IntegrationMethodRegistration["method"],
  416. ): Integration.Method {
  417. if (value.type === "env") return value
  418. if (value.type === "key") return value
  419. if (value.type === "command") {
  420. return {
  421. ...value,
  422. id: Integration.MethodID.make(value.id),
  423. command: [...value.command],
  424. }
  425. }
  426. return {
  427. ...value,
  428. id: Integration.MethodID.make(value.id),
  429. }
  430. }
  431. function agentInfo(value: Agent.Info) {
  432. return {
  433. ...value,
  434. model: value.model && { ...value.model },
  435. request: {
  436. settings: { ...value.request.settings },
  437. headers: { ...value.request.headers },
  438. body: { ...value.request.body },
  439. },
  440. permissions: value.permissions.map((permission) => ({ ...permission })),
  441. }
  442. }
  443. function providerInfo(value: Provider.MutableInfo) {
  444. return {
  445. ...value,
  446. settings: value.settings && { ...value.settings },
  447. headers: value.headers && { ...value.headers },
  448. body: value.body && { ...value.body },
  449. }
  450. }
  451. function modelInfo(value: Model.Info | Model.MutableInfo) {
  452. return {
  453. ...value,
  454. settings: value.settings && { ...value.settings },
  455. headers: value.headers && { ...value.headers },
  456. body: value.body && { ...value.body },
  457. capabilities: {
  458. ...value.capabilities,
  459. input: [...value.capabilities.input],
  460. output: [...value.capabilities.output],
  461. },
  462. variants: value.variants?.map((variant) => ({
  463. ...variant,
  464. settings: variant.settings && { ...variant.settings },
  465. headers: variant.headers && { ...variant.headers },
  466. body: variant.body && { ...variant.body },
  467. })),
  468. time: { ...value.time },
  469. cost: value.cost.map((cost) => ({ ...cost, tier: cost.tier && { ...cost.tier }, cache: { ...cost.cache } })),
  470. limit: { ...value.limit },
  471. }
  472. }