host.ts 19 KB

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