install-github.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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: [
  79. ...pipe(
  80. providers,
  81. values(),
  82. sortBy(
  83. (x) => priority[x.id] ?? 99,
  84. (x) => x.name ?? x.id,
  85. ),
  86. map((x) => ({
  87. label: x.name,
  88. value: x.id,
  89. hint: priority[x.id] === 0 ? "recommended" : undefined,
  90. })),
  91. ),
  92. {
  93. value: "other",
  94. label: "Other",
  95. },
  96. ],
  97. })
  98. if (prompts.isCancel(provider)) throw new UI.CancelledError()
  99. if (provider === "other") {
  100. provider = await prompts.text({
  101. message: "Enter provider id",
  102. validate: (x) => (x.match(/^[a-z-]+$/) ? undefined : "a-z and hyphens only"),
  103. })
  104. if (prompts.isCancel(provider)) throw new UI.CancelledError()
  105. provider = provider.replace(/^@ai-sdk\//, "")
  106. if (prompts.isCancel(provider)) throw new UI.CancelledError()
  107. prompts.log.warn(
  108. `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
  109. )
  110. }
  111. return provider
  112. }
  113. async function promptModel() {
  114. const providerData = providers[provider]!
  115. const model = await prompts.select({
  116. message: "Select model",
  117. maxItems: 8,
  118. options: pipe(
  119. providerData.models,
  120. values(),
  121. sortBy((x) => x.name ?? x.id),
  122. map((x) => ({
  123. label: x.name ?? x.id,
  124. value: x.id,
  125. })),
  126. ),
  127. })
  128. if (prompts.isCancel(model)) throw new UI.CancelledError()
  129. return model
  130. }
  131. async function promptKey() {
  132. const key = await prompts.password({
  133. message: "Enter your API key",
  134. validate: (x) => (x.length > 0 ? undefined : "Required"),
  135. })
  136. if (prompts.isCancel(key)) throw new UI.CancelledError()
  137. return key
  138. }
  139. async function installGitHubApp() {
  140. const s = prompts.spinner()
  141. s.start("Installing GitHub app")
  142. // Get installation
  143. const installation = await getInstallation()
  144. if (installation) return s.stop("GitHub app already installed")
  145. // Open browser
  146. const url = "https://github.com/apps/opencode-agent"
  147. const command =
  148. process.platform === "darwin"
  149. ? `open "${url}"`
  150. : process.platform === "win32"
  151. ? `start "${url}"`
  152. : `xdg-open "${url}"`
  153. exec(command, (error) => {
  154. if (error) {
  155. prompts.log.warn(`Could not open browser. Please visit: ${url}`)
  156. }
  157. })
  158. // Wait for installation
  159. s.message("Waiting for GitHub app to be installed")
  160. const MAX_RETRIES = 60
  161. let retries = 0
  162. do {
  163. const installation = await getInstallation()
  164. if (installation) break
  165. if (retries > MAX_RETRIES) {
  166. s.stop(
  167. `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`,
  168. )
  169. throw new UI.CancelledError()
  170. }
  171. retries++
  172. await new Promise((resolve) => setTimeout(resolve, 1000))
  173. } while (true)
  174. s.stop("Installed GitHub app")
  175. async function getInstallation() {
  176. return await fetch(`https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`)
  177. .then((res) => res.json())
  178. .then((data) => data.installation)
  179. }
  180. }
  181. async function addWorkflowFiles() {
  182. const envStr =
  183. provider === "amazon-bedrock"
  184. ? ""
  185. : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
  186. await Bun.write(
  187. path.join(app.root, WORKFLOW_FILE),
  188. `
  189. name: opencode
  190. on:
  191. issue_comment:
  192. types: [created]
  193. jobs:
  194. opencode:
  195. if: startsWith(github.event.comment.body, 'hey opencode')
  196. runs-on: ubuntu-latest
  197. permissions:
  198. id-token: write
  199. steps:
  200. - name: Checkout repository
  201. uses: actions/checkout@v4
  202. with:
  203. fetch-depth: 1
  204. - name: Run opencode
  205. uses: sst/opencode/sdks/github@github-v1${envStr}
  206. with:
  207. model: ${provider}/${model}
  208. `.trim(),
  209. )
  210. prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`)
  211. }
  212. })
  213. },
  214. })