install-github.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import { $ } from "bun"
  2. import path from "path"
  3. import { exec } from "child_process"
  4. import * as prompts from "@clack/prompts"
  5. import { map, pipe, sortBy, values } from "remeda"
  6. import { UI } from "../ui"
  7. import { cmd } from "./cmd"
  8. import { ModelsDev } from "../../provider/models"
  9. import { App } from "../../app/app"
  10. const WORKFLOW_FILE = ".github/workflows/opencode.yml"
  11. export const InstallGithubCommand = cmd({
  12. command: "install-github",
  13. describe: "install the GitHub agent",
  14. async handler() {
  15. await App.provide({ cwd: process.cwd() }, async () => {
  16. UI.empty()
  17. prompts.intro("Install GitHub agent")
  18. const app = await getAppInfo()
  19. await installGitHubApp()
  20. const providers = await ModelsDev.get()
  21. const provider = await promptProvider()
  22. const model = await promptModel()
  23. //const key = await promptKey()
  24. await addWorkflowFiles()
  25. printNextSteps()
  26. function printNextSteps() {
  27. let step2
  28. if (provider === "amazon-bedrock") {
  29. step2 =
  30. "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services"
  31. } else {
  32. const url = `https://github.com/organizations/${app.owner}/settings/secrets/actions`
  33. const env = providers[provider].env
  34. const envStr =
  35. env.length === 1
  36. ? `\`${env[0]}\` secret`
  37. : `\`${[env.slice(0, -1).join("\`, \`"), ...env.slice(-1)].join("\` and \`")}\` secrets`
  38. step2 = `Add ${envStr} for ${providers[provider].name} - ${url}`
  39. }
  40. prompts.outro(
  41. [
  42. "Next steps:",
  43. ` 1. Commit "${WORKFLOW_FILE}" file and push`,
  44. ` 2. ${step2}`,
  45. " 3. Learn how to use the GitHub agent - https://docs.opencode.ai/docs/github/getting-started",
  46. ].join("\n"),
  47. )
  48. }
  49. async function getAppInfo() {
  50. const app = App.info()
  51. if (!app.git) {
  52. prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
  53. throw new UI.CancelledError()
  54. }
  55. // Get repo info
  56. const info = await $`git remote get-url origin`.quiet().nothrow().text()
  57. // match https or git pattern
  58. // ie. https://github.com/sst/opencode.git
  59. // ie. [email protected]:sst/opencode.git
  60. const parsed = info.match(/git@github\.com:(.*)\.git/) ?? info.match(/github\.com\/(.*)\.git/)
  61. if (!parsed) {
  62. prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
  63. throw new UI.CancelledError()
  64. }
  65. const [owner, repo] = parsed[1].split("/")
  66. return { owner, repo, root: app.path.root }
  67. }
  68. async function promptProvider() {
  69. const priority: Record<string, number> = {
  70. anthropic: 0,
  71. "github-copilot": 1,
  72. openai: 2,
  73. google: 3,
  74. }
  75. let provider = await prompts.select({
  76. message: "Select provider",
  77. maxItems: 8,
  78. options: pipe(
  79. providers,
  80. values(),
  81. sortBy(
  82. (x) => priority[x.id] ?? 99,
  83. (x) => x.name ?? x.id,
  84. ),
  85. map((x) => ({
  86. label: x.name,
  87. value: x.id,
  88. hint: priority[x.id] === 0 ? "recommended" : undefined,
  89. })),
  90. ),
  91. })
  92. if (prompts.isCancel(provider)) throw new UI.CancelledError()
  93. return provider
  94. }
  95. async function promptModel() {
  96. const providerData = providers[provider]!
  97. const model = await prompts.select({
  98. message: "Select model",
  99. maxItems: 8,
  100. options: pipe(
  101. providerData.models,
  102. values(),
  103. sortBy((x) => x.name ?? x.id),
  104. map((x) => ({
  105. label: x.name ?? x.id,
  106. value: x.id,
  107. })),
  108. ),
  109. })
  110. if (prompts.isCancel(model)) throw new UI.CancelledError()
  111. return model
  112. }
  113. async function installGitHubApp() {
  114. const s = prompts.spinner()
  115. s.start("Installing GitHub app")
  116. // Get installation
  117. const installation = await getInstallation()
  118. if (installation) return s.stop("GitHub app already installed")
  119. // Open browser
  120. const url = "https://github.com/apps/opencode-agent"
  121. const command =
  122. process.platform === "darwin"
  123. ? `open "${url}"`
  124. : process.platform === "win32"
  125. ? `start "${url}"`
  126. : `xdg-open "${url}"`
  127. exec(command, (error) => {
  128. if (error) {
  129. prompts.log.warn(`Could not open browser. Please visit: ${url}`)
  130. }
  131. })
  132. // Wait for installation
  133. s.message("Waiting for GitHub app to be installed")
  134. const MAX_RETRIES = 60
  135. let retries = 0
  136. do {
  137. const installation = await getInstallation()
  138. if (installation) break
  139. if (retries > MAX_RETRIES) {
  140. s.stop(
  141. `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
  142. )
  143. throw new UI.CancelledError()
  144. }
  145. retries++
  146. await new Promise((resolve) => setTimeout(resolve, 1000))
  147. } while (true)
  148. s.stop("Installed GitHub app")
  149. async function getInstallation() {
  150. return await fetch(`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`)
  151. .then((res) => res.json())
  152. .then((data) => data.installation)
  153. }
  154. }
  155. async function addWorkflowFiles() {
  156. const envStr =
  157. provider === "amazon-bedrock"
  158. ? ""
  159. : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
  160. await Bun.write(
  161. path.join(app.root, WORKFLOW_FILE),
  162. `
  163. name: opencode
  164. on:
  165. issue_comment:
  166. types: [created]
  167. jobs:
  168. opencode:
  169. if: |
  170. startsWith(github.event.comment.body, 'opencode') ||
  171. startsWith(github.event.comment.body, 'hi opencode') ||
  172. startsWith(github.event.comment.body, 'hey opencode') ||
  173. contains(github.event.comment.body, '@opencode-agent')
  174. runs-on: ubuntu-latest
  175. permissions:
  176. id-token: write
  177. steps:
  178. - name: Checkout repository
  179. uses: actions/checkout@v4
  180. with:
  181. fetch-depth: 1
  182. - name: Run opencode
  183. uses: sst/opencode/sdks/github@github-v1${envStr}
  184. with:
  185. model: ${provider}/${model}
  186. `.trim(),
  187. )
  188. prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
  189. }
  190. })
  191. },
  192. })